diff --git a/project/BuildHelper.scala b/project/BuildHelper.scala index 14743d65..036b8ff9 100644 --- a/project/BuildHelper.scala +++ b/project/BuildHelper.scala @@ -119,6 +119,7 @@ object BuildHelper { ) case Some((2, 13)) => Seq( + "-Xsource:3.0", "-Ywarn-unused:params,-implicits" ) ++ std2xOptions ++ optimizerOptions(optimize) case Some((2, 12)) => @@ -135,7 +136,7 @@ object BuildHelper { "-Ywarn-nullary-unit", "-Ywarn-unused:params,-implicits", "-Xfuture", - "-Xsource:2.13", + "-Xsource:3.0", "-Xmax-classfile-name", "242" ) ++ std2xOptions ++ optimizerOptions(optimize) @@ -150,7 +151,7 @@ object BuildHelper { "-Xexperimental", "-Ywarn-unused-import", "-Xfuture", - "-Xsource:2.13", + "-Xsource:3.0", "-Xmax-classfile-name", "242" ) ++ std2xOptions diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/ast/ast.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/ast/ast.scala new file mode 100644 index 00000000..ffa0b04f --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/ast/ast.scala @@ -0,0 +1,104 @@ +package zio.morphir.sexpr.ast + +import zio.Chunk +import zio.morphir.sexpr.internal.* +import zio.morphir.sexpr.SExprEncoder + +sealed trait SExpr { self => + import SExpr.* + import SExprCase.* + def $case: SExprCase[SExpr] + + def fold[Z](f: SExprCase[Z] => Z): Z = self.$case match { + case c @ BoolCase(_) => f(c) + case c @ StrCase(_) => f(c) + case c @ NumCase(_) => f(c) + case NilCase => f(NilCase) + case ConsCase(car, cdr) => f(ConsCase(car.fold(f), cdr.fold(f))) + case QuotedCase(value) => f(QuotedCase(value.fold(f))) + case VectorCase(items) => f(VectorCase(items.map(_.fold(f)))) + } + + final def widen: SExpr = this +} + +object SExpr { + import SExprCase.* + + implicit val encoder: SExprEncoder[SExpr] = SExprEncoder.fromFunction { + case (sexpr: Bool, indent, out) => ??? + case _ => ??? + } + + def bool(value: Boolean): Bool = Bool(value) + + def vector(items: SExpr*): SVector = SVector(Chunk(items: _*)) + + final case class Bool private[sexpr] ($case: BoolCase) extends SExpr + object Bool { + def apply(value: Boolean): Bool = Bool(BoolCase(value)) + def unapply(arg: SExpr): Option[Boolean] = arg.$case match { + case BoolCase(value) => Some(value) + case _ => None + } + } + + final case class Num private[ast] ($case: NumCase) extends SExpr + object Num { + def apply(value: java.math.BigDecimal): Num = Num(NumCase(value)) + def unapply(exp: SExpr): Option[java.math.BigDecimal] = exp.$case match { + case NumCase(value) => Some(value) + case _ => None + } + } + + final case class Str private[ast] ($case: StrCase) extends SExpr + object Str { + def apply(value: String): Str = Str(StrCase(value)) + def unapply(exp: SExpr): Option[String] = exp.$case match { + case StrCase(value) => Some(value) + case _ => None + } + } + + final case class SVector private[sexpr] ($case: VectorCase[SExpr]) extends SExpr + object SVector { + def apply(items: Chunk[SExpr]): SVector = SVector(VectorCase(items)) + def unapply(arg: SExpr): Option[Chunk[SExpr]] = arg.$case match { + case VectorCase(items) => Some(items) + case _ => None + } + } +} + +sealed trait SExprCase[+A] { self => + import SExprCase.* + def map[B](f: A => B): SExprCase[B] = self match { + case BoolCase(value) => BoolCase(value) + case ConsCase(car, cdr) => ConsCase(f(car), f(cdr)) + case StrCase(value) => StrCase(value) + case NilCase => NilCase + case NumCase(value) => NumCase(value) + case QuotedCase(value) => QuotedCase(f(value)) + case VectorCase(items) => VectorCase(items.map(f)) + } + +} +object SExprCase { + sealed trait AtomCase[+A] extends SExprCase[A] + sealed trait CollectionCase[+A] extends SExprCase[A] + sealed trait ListCase[+A] extends CollectionCase[A] + sealed trait SymbolCase[+A] extends AtomCase[A] + + // Leaf Cases + final case class BoolCase(value: Boolean) extends SymbolCase[Nothing] + final case class StrCase(value: String) extends AtomCase[Nothing] + final case class NumCase(value: java.math.BigDecimal) extends AtomCase[Nothing] + case object NilCase extends ListCase[Nothing] + + // Recursive Cases + final case class ConsCase[+A](car: A, cdr: A) extends ListCase[A] + final case class QuotedCase[+A](value: A) extends SExprCase[A] + final case class VectorCase[+A](items: Chunk[A]) extends CollectionCase[A] + +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/decoder.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/decoder.scala new file mode 100644 index 00000000..c43c0e09 --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/decoder.scala @@ -0,0 +1,123 @@ +package zio.morphir.sexpr + +import zio.morphir.sexpr.ast.SExpr +import zio.morphir.sexpr.internal._ + +import scala.util.control.NoStackTrace + +trait SExprDecoder[A] { + self => + + final def decodeSExpr(str: CharSequence): Either[String, A] = + try Right(unsafeDecode(Nil, new FastStringReader(str))) + catch { + case SExprDecoder.UnsafeSExpr(trace) => Left(SExprError.render(trace)) + case _: UnexpectedEnd => + Left("Unexpected end of input") + case _: StackOverflowError => + Left("Unexpected structure") + } + + /** + * Returns a new decoder whose decoded values will be mapped by the specified function. + */ + def map[B](f: A => B): SExprDecoder[B] = + new SExprDecoder[B] { + + def unsafeDecode(trace: List[SExprError], in: RetractReader): B = + f(self.unsafeDecode(trace, in)) + + override final def fromAST(sexpr: SExpr): Either[String, B] = + self.fromAST(sexpr).map(f) + } + + /** + * Returns a new codec whose decoded values will be mapped by the specified function, which may + * itself decide to fail with some type of error. + */ + final def mapOrFail[B](f: A => Either[String, B]): SExprDecoder[B] = new SExprDecoder[B] { + + def unsafeDecode(trace: List[SExprError], in: RetractReader): B = + f(self.unsafeDecode(trace, in)) match { + case Left(err) => + throw SExprDecoder.UnsafeSExpr(SExprError.Message(err) :: trace) + case Right(b) => b + } + + override final def fromAST(sexpr: SExpr): Either[String, B] = + self.fromAST(sexpr).flatMap(f) + } + + /** + * Low-level, unsafe method to decode a value or throw an exception. This method should not be + * called in application code, although it can be implemented for user-defined data structures. + */ + def unsafeDecode(trace: List[SExprError], in: RetractReader): A + + /** + * Returns this decoder but widened to the its given super-type + */ + final def widen[B >: A]: SExprDecoder[B] = self.asInstanceOf[SExprDecoder[B]] + + /** + * Decode a value from an already parsed SExpr AST. + * + * The default implementation encodes the Json to a byte stream and uses decode to parse that. + * Override to provide a more performant implementation. + */ + def fromAST(sexpr: SExpr): Either[String, A] = + decodeSExpr(SExpr.encoder.encodeSExpr(sexpr, None)) + +} + +object SExprDecoder { + type SExprError = zio.morphir.sexpr.SExprError + val SExprError = zio.morphir.sexpr.SExprError + + def apply[A](implicit decoder: SExprDecoder[A]): SExprDecoder[A] = decoder + + final case class UnsafeSExpr(trace: List[SExprError]) + extends Exception("If you see this, a developer made a mistake using SExprDecoder") + with NoStackTrace + + def peekChar[A](partialFunction: PartialFunction[Char, SExprDecoder[A]]): SExprDecoder[A] = new SExprDecoder[A] { + override def unsafeDecode(trace: List[SExprError], in: RetractReader): A = { + val c = in.nextNonWhitespace() + if (partialFunction.isDefinedAt(c)) { + in.retract() + partialFunction(c).unsafeDecode(trace, in) + } else { + throw UnsafeSExpr(SExprError.Message(s"missing case in `peekChar` for '${c}''") :: trace) + } + } + } + + implicit val string: SExprDecoder[String] = new SExprDecoder[String] { + + def unsafeDecode(trace: List[SExprError], in: RetractReader): String = + Lexer.string(trace, in).toString + + override final def fromAST(sexpr: SExpr): Either[String, String] = + sexpr match { + case SExpr.Str(value) => Right(value) + case _ => Left("Not a string value") + } + } + + implicit val boolean: SExprDecoder[Boolean] = new SExprDecoder[Boolean] { + + def unsafeDecode(trace: List[SExprError], in: RetractReader): Boolean = + Lexer.boolean(trace, in) + + override final def fromAST(sexpr: SExpr): Either[String, Boolean] = + sexpr match { + case SExpr.Bool(value) => Right(value) + case _ => Left("Not a bool value") + } + } + + implicit val char: SExprDecoder[Char] = string.mapOrFail { + case str if str.length == 1 => Right(str(0)) + case _ => Left("expected one character") + } +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/encoder.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/encoder.scala new file mode 100644 index 00000000..b7d07b9b --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/encoder.scala @@ -0,0 +1,178 @@ +package zio.morphir.sexpr + +import scala.annotation._ +import zio.morphir.sexpr.ast.SExpr +import zio.morphir.sexpr.internal._ + +import java.math.{BigDecimal, BigInteger} +import java.util.UUID +import scala.math.{BigDecimal => ScalaBigDecimal, BigInt => ScalaBigInt} + +trait SExprEncoder[A] { self => + + /** + * Returns a new encoder, with a new input type, which can be transformed to the old input type by + * the specified user-defined function. + */ + final def contramap[B](f: B => A): SExprEncoder[B] = new SExprEncoder[B] { + + override def unsafeEncode(b: B, indent: Option[Int], out: Write): Unit = + self.unsafeEncode(f(b), indent, out) + + override def isNothing(b: B): Boolean = self.isNothing(f(b)) + + override final def toAST(b: B): Either[String, SExpr] = + self.toAST(f(b)) + } + + /** + * Encodes the specified value into a SExpr string, with the specified indentation level. + */ + final def encodeSExpr(a: A, indent: Option[Int]): CharSequence = { + val writer = new FastStringWrite(64) + unsafeEncode(a, indent, writer) + writer.buffer + } + + /** + * This default may be overriden when this value may be missing within a JSON object and still be + * encoded. + */ + @nowarn("msg=is never used") + def isNothing(a: A): Boolean = false + + @nowarn("msg=is never used") + def xmap[B](f: A => B, g: B => A): SExprEncoder[B] = contramap(g) + + def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit + + def toAST(a: A): Either[String, SExpr] = + // SExpr.decoder.decodeJson(encodeJson(a, None)) + ??? + + /** + * Returns this encoder but narrowed to the its given sub-type + */ + final def narrow[B <: A]: SExprEncoder[B] = self.asInstanceOf[SExprEncoder[B]] + +} + +object SExprEncoder extends EncoderLowPriority1 { + def apply[A](implicit encoder: SExprEncoder[A]): SExprEncoder[A] = encoder + def fromFunction[A](encodeFn: (A, Option[Int], Write) => Unit): SExprEncoder[A] = new SExprEncoder[A] { + def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = encodeFn(a, indent, out) + } + + implicit val string: SExprEncoder[String] = new SExprEncoder[String] { + + override def unsafeEncode(a: String, indent: Option[Int], out: Write): Unit = { + out.write('"') + var i = 0 + val len = a.length + while (i < len) { + (a.charAt(i): @switch) match { + case '"' => out.write("\\\"") + case '\\' => out.write("\\\\") + case '\b' => out.write("\\b") + case '\f' => out.write("\\f") + case '\n' => out.write("\\n") + case '\r' => out.write("\\r") + case '\t' => out.write("\\t") + case c => + if (c < ' ') out.write("\\u%04x".format(c.toInt)) + else out.write(c) + } + i += 1 + } + out.write('"') + } + + override final def toAST(a: String): Either[String, SExpr] = + Right(SExpr.Str(a)) + } + + implicit val char: SExprEncoder[Char] = new SExprEncoder[Char] { + override def unsafeEncode(a: Char, indent: Option[Int], out: Write): Unit = { + out.write('"') + + (a: @switch) match { + case '"' => out.write("\\\"") + case '\\' => out.write("\\\\") + case c => + if (c < ' ') out.write("\\u%04x".format(c.toInt)) + else out.write(c) + } + out.write('"') + } + + override def toAST(a: Char): Either[String, SExpr] = + Right(SExpr.Str(a.toString)) + } + + private[sexpr] def explicit[A](f: A => String, g: A => SExpr): SExprEncoder[A] = new SExprEncoder[A] { + def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = out.write(f(a)) + + override final def toAST(a: A): Either[String, SExpr] = + Right(g(a)) + } + + private[sexpr] def stringify[A](f: A => String): SExprEncoder[A] = new SExprEncoder[A] { + def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = { + out.write('"') + out.write(f(a)) + out.write('"') + } + + override final def toAST(a: A): Either[String, SExpr] = + Right(SExpr.Str(f(a))) + } + + implicit val boolean: SExprEncoder[Boolean] = explicit(_.toString, SExpr.Bool.apply) + implicit val byte: SExprEncoder[Byte] = explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n.toInt))) + implicit val int: SExprEncoder[Int] = explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n))) + implicit val short: SExprEncoder[Short] = explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n.toInt))) + implicit val long: SExprEncoder[Long] = explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n))) + implicit val bigInteger: SExprEncoder[BigInteger] = explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n))) + implicit val scalaBigInt: SExprEncoder[ScalaBigInt] = + explicit(_.toString, n => SExpr.Num(new java.math.BigDecimal(n.bigInteger))) + implicit val double: SExprEncoder[Double] = + explicit(SafeNumbers.toString, n => SExpr.Num(new java.math.BigDecimal(n))) + implicit val float: SExprEncoder[Float] = + explicit(SafeNumbers.toString, n => SExpr.Num(new java.math.BigDecimal(n.toDouble))) + implicit val bigDecimal: SExprEncoder[BigDecimal] = explicit(_.toString, SExpr.Num.apply) + implicit val scalaBigDecimal: SExprEncoder[ScalaBigDecimal] = explicit(_.toString, n => SExpr.Num(n.bigDecimal)) + +} + +private[sexpr] trait EncoderLowPriority1 extends EncoderLowPriority2 { + self: SExprEncoder.type => +} + +private[sexpr] trait EncoderLowPriority2 extends EncoderLowPriority3 { + self: SExprEncoder.type => +} + +private[sexpr] trait EncoderLowPriority3 { + self: SExprEncoder.type => + + import java.time._ + + implicit val dayOfWeek: SExprEncoder[DayOfWeek] = stringify(_.toString) + implicit val duration: SExprEncoder[Duration] = stringify(serializers.toString) + implicit val instant: SExprEncoder[Instant] = stringify(serializers.toString) + implicit val localDate: SExprEncoder[LocalDate] = stringify(serializers.toString) + implicit val localDateTime: SExprEncoder[LocalDateTime] = stringify(serializers.toString) + implicit val localTime: SExprEncoder[LocalTime] = stringify(serializers.toString) + implicit val month: SExprEncoder[Month] = stringify(_.toString) + implicit val monthDay: SExprEncoder[MonthDay] = stringify(serializers.toString) + implicit val offsetDateTime: SExprEncoder[OffsetDateTime] = stringify(serializers.toString) + implicit val offsetTime: SExprEncoder[OffsetTime] = stringify(serializers.toString) + implicit val period: SExprEncoder[Period] = stringify(serializers.toString) + implicit val year: SExprEncoder[Year] = stringify(serializers.toString) + implicit val yearMonth: SExprEncoder[YearMonth] = stringify(serializers.toString) + implicit val zonedDateTime: SExprEncoder[ZonedDateTime] = stringify(serializers.toString) + implicit val zoneId: SExprEncoder[ZoneId] = stringify(serializers.toString) + implicit val zoneOffset: SExprEncoder[ZoneOffset] = stringify(serializers.toString) + + implicit val uuid: SExprEncoder[UUID] = stringify(_.toString) +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/errors.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/errors.scala new file mode 100644 index 00000000..05605fcf --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/errors.scala @@ -0,0 +1,13 @@ +package zio.morphir.sexpr + +sealed abstract class SExprError +object SExprError { + final case class Message(text: String) extends SExprError + final case class IndexedAccess(index: Int) extends SExprError + + def render(trace: List[SExprError]): String = + trace.reverse.map { + case Message(text) => s"($text)" + case IndexedAccess(index) => s"[$index]" + }.mkString +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/lexer.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/lexer.scala new file mode 100644 index 00000000..e154972f --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/lexer.scala @@ -0,0 +1,285 @@ +package zio.morphir.sexpr.internal + +import zio.morphir.sexpr.SExprDecoder.{SExprError, UnsafeSExpr} + +import scala.annotation._ + +object Lexer { + val NumberMaxBits: Int = 128 + + // TODO: Rename to firstVectorElement + // True if we got anything besides a ], False for ] + def firstArrayElement(in: RetractReader): Boolean = + (in.nextNonWhitespace(): @switch) match { + case ']' => false + case _ => + in.retract() + true + } + + // TODO: Rename to nextVectorElement + def nextArrayElement(trace: List[SExprError], in: OneCharReader): Boolean = + (in.nextNonWhitespace(): @switch) match { + case ',' => true + case ']' => false + case c => + throw UnsafeSExpr( + SExprError.Message(s"expected ',' or ']' got '$c'") :: trace + ) + } + + private[this] val ull: Array[Char] = "ull".toCharArray + private[this] val alse: Array[Char] = "alse".toCharArray + private[this] val rue: Array[Char] = "rue".toCharArray + + def skipValue(trace: List[SExprError], in: RetractReader): Unit = + (in.nextNonWhitespace(): @switch) match { + case 'n' => readChars(trace, in, ull, "null") + case 'f' => readChars(trace, in, alse, "false") + case 't' => readChars(trace, in, rue, "true") + case '[' => + if (firstArrayElement(in)) { + while ({ + skipValue(trace, in); + nextArrayElement(trace, in) + }) () + } + case '"' => + skipString(trace, in) + case '-' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => + skipNumber(in) + case c => throw UnsafeSExpr(SExprError.Message(s"unexpected '$c'") :: trace) + } + + def skipNumber(in: RetractReader): Unit = { + while (isNumber(in.readChar())) {} + in.retract() + } + + def skipString(trace: List[SExprError], in: OneCharReader): Unit = { + val stream = new EscapedString(trace, in) + var i: Int = 0 + while ({ i = stream.read(); i != -1 }) () + } + + // useful for embedded documents, e.g. CSV contained inside JSON + def streamingString(trace: List[SExprError], in: OneCharReader): java.io.Reader = { + char(trace, in, '"') + new EscapedString(trace, in) + } + + def string(trace: List[SExprError], in: OneCharReader): CharSequence = { + char(trace, in, '"') + val stream = new EscapedString(trace, in) + + val sb = new FastStringBuilder(64) + while (true) { + val c = stream.read() + if (c == -1) + return sb.buffer // mutable thing escapes, but cannot be changed + sb.append(c.toChar) + } + throw UnsafeSExpr(SExprError.Message("impossible string") :: trace) + } + + def boolean(trace: List[SExprError], in: OneCharReader): Boolean = + (in.nextNonWhitespace(): @switch) match { + case 't' => + readChars(trace, in, rue, "true") + true + case 'f' => + readChars(trace, in, alse, "false") + false + case c => + throw UnsafeSExpr( + SExprError.Message(s"expected 'true' or 'false' got $c") :: trace + ) + } + + // optional whitespace and then an expected character + @inline def char(trace: List[SExprError], in: OneCharReader, c: Char): Unit = { + val got = in.nextNonWhitespace() + if (got != c) + throw UnsafeSExpr(SExprError.Message(s"expected '$c' got '$got'") :: trace) + } + + @inline def charOnly( + trace: List[SExprError], + in: OneCharReader, + c: Char + ): Unit = { + val got = in.readChar() + if (got != c) + throw UnsafeSExpr(SExprError.Message(s"expected '$c' got '$got'") :: trace) + } + + // non-positional for performance + @inline private[this] def isNumber(c: Char): Boolean = + (c: @switch) match { + case '+' | '-' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '.' | 'e' | 'E' => + true + case _ => false + } + + def readChars( + trace: List[SExprError], + in: OneCharReader, + expect: Array[Char], + errMsg: String + ): Unit = { + var i: Int = 0 + while (i < expect.length) { + if (in.readChar() != expect(i)) + throw UnsafeSExpr(SExprError.Message(s"expected '$errMsg'") :: trace) + i += 1 + } + } + +} + +// A Reader for the contents of a string, taking care of the escaping. +// +// `read` can throw extra exceptions on badly formed input. +private final class EscapedString(trace: List[SExprError], in: OneCharReader) + extends java.io.Reader + with OneCharReader { + + def close(): Unit = in.close() + + private[this] var escaped = false + + override def read(): Int = { + val c = in.readChar() + if (escaped) { + escaped = false + (c: @switch) match { + case '"' | '\\' | '/' => c.toInt + case 'b' => '\b'.toInt + case 'f' => '\f'.toInt + case 'n' => '\n'.toInt + case 'r' => '\r'.toInt + case 't' => '\t'.toInt + case 'u' => nextHex4() + case _ => + throw UnsafeSExpr( + SExprError.Message(s"invalid '\\${c.toChar}' in string") :: trace + ) + } + } else if (c == '\\') { + escaped = true + read() + } else if (c == '"') -1 // this is the EOS for the caller + else if (c < ' ') + throw UnsafeSExpr(SExprError.Message("invalid control in string") :: trace) + else c.toInt + } + + // callers expect to get an EOB so this is rare + def readChar(): Char = { + val v = read() + if (v == -1) throw new UnexpectedEnd + v.toChar + } + + // consumes 4 hex characters after current + def nextHex4(): Int = { + var i: Int = 0 + var accum: Int = 0 + while (i < 4) { + var c: Int = in.read() + if (c == -1) + throw UnsafeSExpr(SExprError.Message("unexpected EOB in string") :: trace) + c = + if ('0' <= c && c <= '9') c - '0' + else if ('A' <= c && c <= 'F') c - 'A' + 10 + else if ('a' <= c && c <= 'f') c - 'a' + 10 + else + throw UnsafeSExpr( + SExprError.Message("invalid charcode in string") :: trace + ) + accum = accum * 16 + c + i += 1 + } + accum + } +} + +// A data structure encoding a simple algorithm for Trie pruning: Given a list +// of strings, and a sequence of incoming characters, find the strings that +// match, by manually maintaining a bitset. Empty strings are not allowed. +final class StringMatrix(val xs: Array[String]) { + require(xs.forall(_.nonEmpty)) + require(xs.nonEmpty) + require(xs.length < 64) + + val width = xs.length + val height: Int = xs.map(_.length).max + val lengths: Array[Int] = xs.map(_.length) + val initial: Long = (0 until width).foldLeft(0L)((bs, r) => bs | (1L << r)) + + private val matrix: Array[Int] = { + val m = Array.fill[Int](width * height)(-1) + var string: Int = 0 + while (string < width) { + val s = xs(string) + val len = s.length + var char: Int = 0 + while (char < len) { + m(width * char + string) = s.codePointAt(char) + char += 1 + } + string += 1 + } + m + } + + // must be called with increasing `char` (starting with bitset obtained from a + // call to 'initial', char = 0) + def update(bitset: Long, char: Int, c: Int): Long = + if (char >= height) 0L // too long + else if (bitset == 0L) 0L // everybody lost + else { + var latest: Long = bitset + val base: Int = width * char + + if (bitset == initial) { // special case when it is dense since it is simple + var string: Int = 0 + while (string < width) { + if (matrix(base + string) != c) + latest = latest ^ (1L << string) + string += 1 + } + } else { + var remaining: Long = bitset + while (remaining != 0L) { + val string: Int = java.lang.Long.numberOfTrailingZeros(remaining) + val bit: Long = 1L << string + if (matrix(base + string) != c) + latest = latest ^ bit + remaining = remaining ^ bit + } + } + + latest + } + + // excludes entries that are not the given exact length + def exact(bitset: Long, length: Int): Long = + if (length > height) 0L // too long + else { + var latest: Long = bitset + var remaining: Long = bitset + while (remaining != 0L) { + val string: Int = java.lang.Long.numberOfTrailingZeros(remaining) + val bit: Long = 1L << string + if (lengths(string) != length) + latest = latest ^ bit + remaining = remaining ^ bit + } + latest + } + + def first(bitset: Long): Int = + if (bitset == 0L) -1 + else java.lang.Long.numberOfTrailingZeros(bitset) // never returns 64 +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/serializers.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/serializers.scala new file mode 100644 index 00000000..d377dd74 --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/serializers.scala @@ -0,0 +1,254 @@ +package zio.morphir.sexpr.internal + +import java.time._ + +private[sexpr] object serializers { + def toString(x: Duration): String = { + val s = new java.lang.StringBuilder(16) + s.append('P').append('T') + val totalSecs = x.getSeconds + var nano = x.getNano + if ((totalSecs | nano) == 0) s.append('0').append('S') + else { + var effectiveTotalSecs = totalSecs + if (totalSecs < 0 && nano > 0) effectiveTotalSecs += 1 + val hours = effectiveTotalSecs / 3600 // 3600 == seconds in a hour + val secsOfHour = (effectiveTotalSecs - hours * 3600).toInt + val minutes = secsOfHour / 60 + val seconds = secsOfHour - minutes * 60 + if (hours != 0) s.append(hours).append('H') + if (minutes != 0) s.append(minutes).append('M') + if ((seconds | nano) != 0) { + if (totalSecs < 0 && seconds == 0) s.append('-').append('0') + else s.append(seconds) + if (nano != 0) { + if (totalSecs < 0) nano = 1000000000 - nano + val dotPos = s.length + s.append(nano + 1000000000) + var i = s.length - 1 + while (s.charAt(i) == '0') i -= 1 + s.setLength(i + 1) + s.setCharAt(dotPos, '.') + } + s.append('S') + } + } + s.toString + } + + def toString(x: Instant): String = { + val s = new java.lang.StringBuilder(32) + val epochSecond = x.getEpochSecond + val epochDay = + (if (epochSecond >= 0) epochSecond + else epochSecond - 86399) / 86400 // 86400 == seconds per day + val secsOfDay = (epochSecond - epochDay * 86400).toInt + var marchZeroDay = + epochDay + 719468 // 719468 == 719528 - 60 == days 0000 to 1970 - days 1st Jan to 1st Mar + var adjustYear = 0 + if (marchZeroDay < 0) { // adjust negative years to positive for calculation + val adjust400YearCycles = to400YearCycle(marchZeroDay + 1) - 1 + adjustYear = adjust400YearCycles * 400 + marchZeroDay -= adjust400YearCycles * 146097L + } + var year = to400YearCycle(marchZeroDay * 400 + 591) + var marchDayOfYear = toMarchDayOfYear(marchZeroDay, year) + if (marchDayOfYear < 0) { // fix year estimate + year -= 1 + marchDayOfYear = toMarchDayOfYear(marchZeroDay, year) + } + val marchMonth = (marchDayOfYear * 17135 + 6854) >> 19 // (marchDayOfYear * 5 + 2) / 153 + year += (marchMonth * 3277 >> 15) + adjustYear // year += marchMonth / 10 + adjustYear (reset any negative year and convert march-based values back to january-based) + val month = marchMonth + + (if (marchMonth < 10) 3 + else -9) + val day = + marchDayOfYear - ((marchMonth * 1002762 - 16383) >> 15) // marchDayOfYear - (marchMonth * 306 + 5) / 10 + 1 + val hour = secsOfDay * 37283 >>> 27 // divide a small positive int by 3600 + val secsOfHour = secsOfDay - hour * 3600 + val minute = secsOfHour * 17477 >> 20 // divide a small positive int by 60 + val second = secsOfHour - minute * 60 + appendYear(year, s) + append2Digits(month, s.append('-')) + append2Digits(day, s.append('-')) + append2Digits(hour, s.append('T')) + append2Digits(minute, s.append(':')) + append2Digits(second, s.append(':')) + val nano = x.getNano + if (nano != 0) { + s.append('.') + val q1 = nano / 1000000 + val r1 = nano - q1 * 1000000 + append3Digits(q1, s) + if (r1 != 0) { + val q2 = r1 / 1000 + val r2 = r1 - q2 * 1000 + append3Digits(q2, s) + if (r2 != 0) append3Digits(r2, s) + } + } + s.append('Z').toString + } + + def toString(x: LocalDate): String = { + val s = new java.lang.StringBuilder(16) + appendLocalDate(x, s) + s.toString + } + + def toString(x: LocalDateTime): String = { + val s = new java.lang.StringBuilder(32) + appendLocalDate(x.toLocalDate, s) + appendLocalTime(x.toLocalTime, s.append('T')) + s.toString + } + + def toString(x: LocalTime): String = { + val s = new java.lang.StringBuilder(24) + appendLocalTime(x, s) + s.toString + } + + def toString(x: MonthDay): String = { + val s = new java.lang.StringBuilder(8) + append2Digits(x.getMonthValue, s.append('-').append('-')) + append2Digits(x.getDayOfMonth, s.append('-')) + s.toString + } + + def toString(x: OffsetDateTime): String = { + val s = new java.lang.StringBuilder(48) + appendLocalDate(x.toLocalDate, s) + appendLocalTime(x.toLocalTime, s.append('T')) + appendZoneOffset(x.getOffset, s) + s.toString + } + + def toString(x: OffsetTime): String = { + val s = new java.lang.StringBuilder(32) + appendLocalTime(x.toLocalTime, s) + appendZoneOffset(x.getOffset, s) + s.toString + } + + def toString(x: Period): String = { + val s = new java.lang.StringBuilder(16) + s.append('P') + if (x.isZero) s.append('0').append('D') + else { + val years = x.getYears + val months = x.getMonths + val days = x.getDays + if (years != 0) s.append(years).append('Y') + if (months != 0) s.append(months).append('M') + if (days != 0) s.append(days).append('D') + } + s.toString + } + + def toString(x: Year): String = { + val s = new java.lang.StringBuilder(16) + appendYear(x.getValue, s) + s.toString + } + + def toString(x: YearMonth): String = { + val s = new java.lang.StringBuilder(16) + appendYear(x.getYear, s) + append2Digits(x.getMonthValue, s.append('-')) + s.toString + } + + def toString(x: ZonedDateTime): String = { + val s = new java.lang.StringBuilder(48) + appendLocalDate(x.toLocalDate, s) + appendLocalTime(x.toLocalTime, s.append('T')) + appendZoneOffset(x.getOffset, s) + val zone = x.getZone + if (!zone.isInstanceOf[ZoneOffset]) s.append('[').append(zone.getId).append(']') + s.toString + } + + def toString(x: ZoneId): String = x.getId + + def toString(x: ZoneOffset): String = { + val s = new java.lang.StringBuilder(16) + appendZoneOffset(x, s) + s.toString + } + + private[this] def appendLocalDate(x: LocalDate, s: java.lang.StringBuilder): Unit = { + appendYear(x.getYear, s) + append2Digits(x.getMonthValue, s.append('-')) + append2Digits(x.getDayOfMonth, s.append('-')) + } + + private[this] def appendLocalTime(x: LocalTime, s: java.lang.StringBuilder): Unit = { + append2Digits(x.getHour, s) + append2Digits(x.getMinute, s.append(':')) + append2Digits(x.getSecond, s.append(':')) + val nano = x.getNano + if (nano != 0) { + val dotPos = s.length + s.append(nano + 1000000000) + var i = s.length - 1 + while (s.charAt(i) == '0') i -= 1 + s.setLength(i + 1) + s.setCharAt(dotPos, '.') + } + } + + private[this] def appendZoneOffset(x: ZoneOffset, s: java.lang.StringBuilder): Unit = { + val totalSeconds = x.getTotalSeconds + if (totalSeconds == 0) s.append('Z'): Unit + else { + val q0 = + if (totalSeconds > 0) { + s.append('+') + totalSeconds + } else { + s.append('-') + -totalSeconds + } + val q1 = q0 * 37283 >>> 27 // divide a small positive int by 3600 + val r1 = q0 - q1 * 3600 + append2Digits(q1, s) + s.append(':') + val q2 = r1 * 17477 >> 20 // divide a small positive int by 60 + val r2 = r1 - q2 * 60 + append2Digits(q2, s) + if (r2 != 0) append2Digits(r2, s.append(':')) + } + } + + private[this] def appendYear(x: Int, s: java.lang.StringBuilder): Unit = + if (x >= 0) { + if (x < 10000) append4Digits(x, s) + else s.append('+').append(x): Unit + } else if (x > -10000) append4Digits(-x, s.append('-')) + else s.append(x): Unit + + private[this] def append4Digits(x: Int, s: java.lang.StringBuilder): Unit = { + val q = x * 5243 >> 19 // divide a 4-digit positive int by 100 + append2Digits(q, s) + append2Digits(x - q * 100, s) + } + + private[this] def append3Digits(x: Int, s: java.lang.StringBuilder): Unit = { + val q = x * 1311 >> 17 // divide a 3-digit positive int by 100 + append2Digits(x - q * 100, s.append((q + '0').toChar)) + } + + private[this] def append2Digits(x: Int, s: java.lang.StringBuilder): Unit = { + val q = x * 103 >> 10 // divide a 2-digit positive int by 10 + s.append((q + '0').toChar).append((x + '0' - q * 10).toChar): Unit + } + + private[this] def to400YearCycle(day: Long): Int = + (day / 146097).toInt // 146097 == number of days in a 400 year cycle + + private[this] def toMarchDayOfYear(marchZeroDay: Long, year: Int): Int = { + val century = year / 100 + (marchZeroDay - year * 365L).toInt - (year >> 2) + century - (century >> 2) + } +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/writers.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/writers.scala new file mode 100644 index 00000000..fe277113 --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/internal/writers.scala @@ -0,0 +1,43 @@ +// Implementations of java.io.Writer that are faster (2x) because they do not +// synchronize on a lock +package zio.morphir.sexpr.internal + +import java.nio.CharBuffer +import java.util.Arrays + +// a minimal subset of java.io.Writer that can be optimised +trait Write { + def write(c: Char): Unit + def write(s: String): Unit +} + +// wrapper to implement the legacy Java API +final class WriteWriter(out: java.io.Writer) extends Write { + def write(s: String): Unit = out.write(s) + def write(c: Char): Unit = out.write(c.toInt) +} + +final class FastStringWrite(initial: Int) extends Write { + private[this] val sb: java.lang.StringBuilder = new java.lang.StringBuilder(initial) + + def write(s: String): Unit = sb.append(s): Unit + + def write(c: Char): Unit = sb.append(c): Unit + + def buffer: CharSequence = sb +} + +// like StringBuilder but doesn't have any encoding or range checks +private[zio] final class FastStringBuilder(initial: Int) { + private[this] var chars: Array[Char] = new Array[Char](initial) + private[this] var i: Int = 0 + + def append(c: Char): Unit = { + if (i == chars.length) + chars = Arrays.copyOf(chars, chars.length * 2) + chars(i) = c + i += 1 + } + + def buffer: CharSequence = CharBuffer.wrap(chars, 0, i) +} diff --git a/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/package.scala b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/package.scala new file mode 100644 index 00000000..b738fb4a --- /dev/null +++ b/zio-morphir-sexpr/shared/src/main/scala/zio/morphir/sexpr/package.scala @@ -0,0 +1,27 @@ +package zio.morphir + +import zio.morphir.sexpr.ast.SExpr + +package object sexpr { + + implicit class EncoderOps[A](val a: A) extends AnyVal { + def toSExpr(implicit encoder: SExprEncoder[A]): String = + encoder.encodeSExpr(a, None).toString + + def toSExprPretty(implicit encoder: SExprEncoder[A]): String = encoder.encodeSExpr(a, Some(0)).toString + + def toSExprPretty(indent: Int)(implicit encoder: SExprEncoder[A]): String = + encoder.encodeSExpr(a, Some(indent)).toString + + def toSExprAST(implicit encoder: SExprEncoder[A]): Either[String, SExpr] = encoder.toAST(a) + } + + implicit class DecoderOps(val sexpr: CharSequence) extends AnyVal { + def fromSExpr[A](implicit A: SExprDecoder[A]): Either[String, A] = A.decodeSExpr(sexpr) + } + + implicit class SExprOps[A](val sexpr: SExpr) extends AnyVal { + def as[A](implicit A: SExprDecoder[A]): Either[String, A] = A.fromAST(sexpr) + } + +} diff --git a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/DecoderSpec.scala b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/DecoderSpec.scala index aea2438a..39692f14 100644 --- a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/DecoderSpec.scala +++ b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/DecoderSpec.scala @@ -1,7 +1,26 @@ package zio.morphir.sexpr -import zio.test.DefaultRunnableSpec +import zio.morphir.testing.ZioBaseSpec +import zio.test.TestAspect.{ignore, tag} +import zio.test._ -object DecoderSpec extends DefaultRunnableSpec { - def spec = suite("Decoder")() +object DecoderSpec extends ZioBaseSpec { + def spec = suite("Decoder")( + suite("fromSExpr")( + suite("primitives")( + test("string") { + assertTrue( + "\"hello world\"".fromSExpr[String] == Right("hello world"), + "\"hello\\nworld\"".fromSExpr[String] == Right("hello\nworld"), + "\"hello\\rworld\"".fromSExpr[String] == Right("hello\rworld"), + "\"hello\\u0000world\"".fromSExpr[String] == Right("hello\u0000world") + ) + // "hello\u0000world".toSExpr == "\"hello\\u0000world\"" + } + test("boolean") { + assertTrue("true".fromSExpr[Boolean] == Right(true)) && + assertTrue("false".fromSExpr[Boolean] == Right(false)) + } @@ ignore @@ tag("Something isn't working right!") + ) + ) + ) } diff --git a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/EncoderSpec.scala b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/EncoderSpec.scala index c8f67f42..0cbeed33 100644 --- a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/EncoderSpec.scala +++ b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/EncoderSpec.scala @@ -1,7 +1,145 @@ package zio.morphir.sexpr -import zio.test.DefaultRunnableSpec +import zio.morphir.testing.ZioBaseSpec +import zio.test._ -object EncoderSpec extends DefaultRunnableSpec { - def spec = suite("Encoder")() +object EncoderSpec extends ZioBaseSpec { + def spec = suite("Encoder")( + suite("toSExpr")( + suite("primitives")( + test("string") { + assertTrue( + "hello world".toSExpr == "\"hello world\"", + "hello\nworld".toSExpr == "\"hello\\nworld\"", + "hello\rworld".toSExpr == "\"hello\\rworld\"", + "hello\u0000world".toSExpr == "\"hello\\u0000world\"" + ) + } + test("boolean") { + assertTrue(true.toSExpr == "true", false.toSExpr == "false") + } + test("byte") { + assertTrue(1.toSExpr == "1") + } + test("char") { + assertTrue( + 'c'.toSExpr == "\"c\"" + // Symbol("c").toSExpr == "\"c\"" + ) + } + test("float") { + assertTrue( + Float.NaN.toSExpr == "\"NaN\"", + Float.PositiveInfinity.toSExpr == "\"Infinity\"", + Float.NegativeInfinity.toSExpr == "\"-Infinity\"", + 0.0f.toSExpr == "0.0", + (-0.0f).toSExpr == "-0.0", + 1.0f.toSExpr == "1.0", + (-1.0f).toSExpr == "-1.0", + 1.0e7f.toSExpr == "1.0E7", + (-1.0e7f).toSExpr == "-1.0E7", + 1.17549435e-38f.toSExpr == "1.1754944E-38", + 9999999.0f.toSExpr == "9999999.0", + 0.001f.toSExpr == "0.001", + 9.999999e-4f.toSExpr == "9.999999E-4", + 1.4e-45f.toSExpr == "1.4E-45", + 3.3554448e7f.toSExpr == "3.355445E7", + 8.999999e9f.toSExpr == "9.0E9", + 3.4366718e10f.toSExpr == "3.436672E10", + 4.7223665e21f.toSExpr == "4.7223665E21", + 8388608.0f.toSExpr == "8388608.0", + 1.6777216e7f.toSExpr == "1.6777216E7", + 3.3554436e7f.toSExpr == "3.3554436E7", + 6.7131496e7f.toSExpr == "6.7131496E7", + 1.9310392e-38f.toSExpr == "1.9310392E-38", + (-2.47e-43f).toSExpr == "-2.47E-43", + 1.993244e-38f.toSExpr == "1.993244E-38", + 4103.9004f.toSExpr == "4103.9004", + 5.3399997e9f.toSExpr == "5.3399997E9", + 6.0898e-39f.toSExpr == "6.0898E-39", + 0.0010310042f.toSExpr == "0.0010310042", + 2.8823261e17f.toSExpr == "2.882326E17", + 7.038531e-26f.toSExpr == "7.038531E-26", + 9.2234038e17f.toSExpr == "9.223404E17", + 6.7108872e7f.toSExpr == "6.710887E7", + 1.0e-44f.toSExpr == "9.8E-45", + 2.816025e14f.toSExpr == "2.816025E14", + 9.223372e18f.toSExpr == "9.223372E18", + 1.5846086e29f.toSExpr == "1.5846086E29", + 1.1811161e19f.toSExpr == "1.1811161E19", + 5.368709e18f.toSExpr == "5.368709E18", + 4.6143166e18f.toSExpr == "4.6143166E18", + 0.007812537f.toSExpr == "0.007812537", + 1.4e-45f.toSExpr == "1.4E-45", + 1.18697725e20f.toSExpr == "1.18697725E20", + 1.00014165e-36f.toSExpr == "1.00014165E-36", + 200.0f.toSExpr == "200.0", + 3.3554432e7f.toSExpr == "3.3554432E7", + 1.26217745e-29f.toSExpr == "1.2621775E-29", + 1.0e-43f.toSExpr == "9.9E-44", + 1.0e-45f.toSExpr == "1.4E-45", + 7.1e10f.toSExpr == "7.1E10", + 1.1e15f.toSExpr == "1.1E15", + 1.0e17f.toSExpr == "1.0E17", + 6.3e9f.toSExpr == "6.3E9", + 3.0e10f.toSExpr == "3.0E10", + 1.1e10f.toSExpr == "1.1E10", + (-6.9390464e7f).toSExpr == "-6.939046E7", + (-6939.0464f).toSExpr == "-6939.0464" + ) + } + test("double") { + assertTrue( + Double.NaN.toSExpr == "\"NaN\"", + Double.PositiveInfinity.toSExpr == "\"Infinity\"", + Double.NegativeInfinity.toSExpr == "\"-Infinity\"", + 0.0d.toSExpr == "0.0", + (-0.0d).toSExpr == "-0.0", + 1.0d.toSExpr == "1.0", + (-1.0d).toSExpr == "-1.0", + 2.2250738585072014e-308d.toSExpr == "2.2250738585072014E-308", + 1.0e7d.toSExpr == "1.0E7", + (-1.0e7d).toSExpr == "-1.0E7", + 9999999.999999998d.toSExpr == "9999999.999999998", + 0.001d.toSExpr == "0.001", + 9.999999999999998e-4d.toSExpr == "9.999999999999998E-4", + (-1.7976931348623157e308d).toSExpr == "-1.7976931348623157E308", + 4.9e-324d.toSExpr == "4.9E-324", + 1.7976931348623157e308d.toSExpr == "1.7976931348623157E308", + (-2.109808898695963e16d).toSExpr == "-2.109808898695963E16", + 4.940656e-318d.toSExpr == "4.940656E-318", + 1.18575755e-316d.toSExpr == "1.18575755E-316", + 2.989102097996e-312d.toSExpr == "2.989102097996E-312", + 9.0608011534336e15d.toSExpr == "9.0608011534336E15", + 4.708356024711512e18d.toSExpr == "4.708356024711512E18", + 9.409340012568248e18d.toSExpr == "9.409340012568248E18", + 1.8531501765868567e21d.toSExpr == "1.8531501765868567E21", + (-3.347727380279489e33d).toSExpr == "-3.347727380279489E33", + 1.9430376160308388e16d.toSExpr == "1.9430376160308388E16", + (-6.9741824662760956e19d).toSExpr == "-6.9741824662760956E19", + 4.3816050601147837e18d.toSExpr == "4.3816050601147837E18", + 7.1202363472230444e-307d.toSExpr == "7.120236347223045E-307", + 3.67301024534615e16d.toSExpr == "3.67301024534615E16", + 5.9604644775390625e-8d.toSExpr == "5.960464477539063E-8", + 1.0e-322d.toSExpr == "9.9E-323", + 5.0e-324d.toSExpr == "4.9E-324", + 1.0e23d.toSExpr == "1.0E23", + 8.41e21d.toSExpr == "8.41E21", + 7.3879e20d.toSExpr == "7.3879E20", + 3.1e22d.toSExpr == "3.1E22", + 5.63e21d.toSExpr == "5.63E21", + 2.82879384806159e17d.toSExpr == "2.82879384806159E17", + 1.387364135037754e18d.toSExpr == "1.387364135037754E18", + 1.45800632428665e17d.toSExpr == "1.45800632428665E17", + 1.790086667993e18d.toSExpr == "1.790086667993E18", + 2.273317134858e18d.toSExpr == "2.273317134858E18", + 7.68905065813e17d.toSExpr == "7.68905065813E17", + 1.9400994884341945e25d.toSExpr == "1.9400994884341945E25", + 3.6131332396758635e25d.toSExpr == "3.6131332396758635E25", + 2.5138990223946153e25d.toSExpr == "2.5138990223946153E25", + (-3.644554028000364e16d).toSExpr == "-3.644554028000364E16", + (-6939.0464d).toSExpr == "-6939.0464" + ) + } + test("int") { + assertTrue(1.toSExpr == "1") + } + ) + ), + suite("toSExprAst")() + ) } diff --git a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/RoundTripSpec.scala b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/RoundTripSpec.scala new file mode 100644 index 00000000..959bbab9 --- /dev/null +++ b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/sexpr/RoundTripSpec.scala @@ -0,0 +1,22 @@ +package zio.morphir.sexpr + +import zio.morphir.testing.ZioBaseSpec +import zio.test._ +import zio.test.Assertion._ +import zio.test.TestAspect._ + +object RoundTripSpec extends ZioBaseSpec { + + def spec = suite("RoundTrip")( + testM("booleans") { + check(Gen.boolean)(assertRoundtrips) + } @@ ignore @@ tag("Not sure what is broken here"), + testM("char") { + check(Gen.anyChar)(assertRoundtrips) + } + ) + + private def assertRoundtrips[A: SExprEncoder: SExprDecoder](a: A) = + assert(a.toSExpr.fromSExpr[A])(isRight(equalTo(a))) && + assert(a.toSExprPretty.fromSExpr[A])(isRight(equalTo(a))) +} diff --git a/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/testing/ZioBaseSpec.scala b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/testing/ZioBaseSpec.scala new file mode 100644 index 00000000..0b5f523e --- /dev/null +++ b/zio-morphir-sexpr/shared/src/test/scala/zio/morphir/testing/ZioBaseSpec.scala @@ -0,0 +1,9 @@ +package zio.morphir.testing + +import zio.duration._ +import zio.test._ +import zio.test.environment.Live + +trait ZioBaseSpec extends DefaultRunnableSpec { + override def aspects: List[TestAspectAtLeastR[Live]] = List(TestAspect.timeout(60.seconds)) +}