diff --git a/README.md b/README.md index 64edd885..0af9222e 100644 --- a/README.md +++ b/README.md @@ -725,3 +725,17 @@ case class Bar(seq: Seq[Int]) val foo = """{"bar":{"seq":[1,2,3]}}""".jsonAs[Foo].fold(throw _, identity) val json = foo.asJson ``` + +# `tethys-literal` + +Compile-time-validated JSON literals. Scala 2.13 and Scala 3. + +```scala +libraryDependencies += "com.tethys-json" %% "tethys-literal" % tethysVersion +``` + +```scala +import tethys.literal._ + +val rj = json"""{ "name": "Alice" }""" +``` diff --git a/build.sbt b/build.sbt index 0be952eb..08538e3e 100644 --- a/build.sbt +++ b/build.sbt @@ -100,7 +100,8 @@ lazy val tethys = project circe, refined, enumeratum, - cats + cats, + literal ) lazy val modules = file("modules") @@ -206,6 +207,21 @@ lazy val refined = project ) .dependsOn(core) +lazy val literal = project + .in(modules / "literal") + .settings(crossScalaSettings) + .settings(commonSettings) + .settings(testSettings) + .settings( + name := "tethys-literal", + crossScalaVersions := Seq(scala213, scala3), + libraryDependencies ++= Seq( + "com.fasterxml.jackson.core" % "jackson-core" % "2.18.2", + "io.circe" %% "circe-core" % "0.14.15" % Test + ) + ) + .dependsOn(core, `jackson-218` % Test, `macro-derivation` % Test) + lazy val jackson = modules / "backend" / "jackson" lazy val jacksonSettings = Seq( diff --git a/modules/core/src/main/scala/tethys/commons/TokenNode.scala b/modules/core/src/main/scala/tethys/commons/TokenNode.scala index 306978d4..e236bb1e 100644 --- a/modules/core/src/main/scala/tethys/commons/TokenNode.scala +++ b/modules/core/src/main/scala/tethys/commons/TokenNode.scala @@ -109,7 +109,7 @@ object TokenNode { def jsonAsTokensList(implicit producer: TokenIteratorProducer ): List[TokenNode] = { - import tethys._ + import tethys.StringReaderOps val iterator = json.toTokenIterator.fold(throw _, identity) val builder = List.newBuilder[TokenNode] while (!iterator.currentToken().isEmpty) { diff --git a/modules/literal/src/main/scala-2.13+/tethys/literal/JsonInterpolator.scala b/modules/literal/src/main/scala-2.13+/tethys/literal/JsonInterpolator.scala new file mode 100644 index 00000000..b8f3bc8e --- /dev/null +++ b/modules/literal/src/main/scala-2.13+/tethys/literal/JsonInterpolator.scala @@ -0,0 +1,125 @@ +package tethys.literal + +import tethys.commons.RawJson +import tethys.literal.JsonLiteralParser.HolePosition + +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +final class JsonInterpolator(private val sc: StringContext) extends AnyVal { + def json(args: Any*): RawJson = macro JsonInterpolatorMacro.impl +} + +object JsonInterpolator { + implicit def toJsonInterpolator(sc: StringContext): JsonInterpolator = + new JsonInterpolator(sc) +} + +private[literal] object JsonInterpolatorMacro { + def impl(c: blackbox.Context)(args: c.Expr[Any]*): c.Expr[RawJson] = { + import c.universe._ + + val parts: List[String] = c.prefix.tree match { + case Apply(_, List(Apply(_, rawParts))) => + rawParts.map { + case Literal(Constant(s: String)) => s + case other => + c.abort( + other.pos, + "json interpolator requires constant string parts" + ) + } + case other => + c.abort( + other.pos, + "Unexpected prefix shape for json interpolator" + ) + } + + val argTrees: List[Tree] = args.iterator.map(_.tree).toList + + if (parts.length != argTrees.length + 1) + c.abort( + c.enclosingPosition, + s"Malformed StringContext: ${parts.length} parts, ${argTrees.length} args" + ) + + val badIdx = JsonLiteralParser.findHoleInString(parts) + if (badIdx >= 0) + c.abort( + argTrees(badIdx).pos, + "Interpolation inside a string literal is not supported — move the placeholder into a value or key position" + ) + + val template = JsonLiteralParser.assembleTemplate(parts) + val holes = JsonLiteralParser.parse(template, argTrees.size) match { + case Right(hs) => hs + case Left(err) => + c.abort( + c.enclosingPosition, + s"Invalid JSON literal (at offset ${err.offset}): ${err.message}" + ) + } + + val producerTpe = c.weakTypeOf[tethys.writers.tokens.TokenWriterProducer] + val producerTree = + if (argTrees.isEmpty) EmptyTree + else { + val found = c.inferImplicitValue(producerTpe) + if (found.isEmpty) + c.abort( + c.enclosingPosition, + "No implicit TokenWriterProducer in scope. Did you forget `import tethys.jackson._`?" + ) + found + } + + val positionByIndex: Map[Int, HolePosition] = + holes.iterator.map(h => h.index -> h.position).toMap + + def renderValue(arg: Tree): Tree = { + val tpe = c.typecheck(arg.duplicate, silent = false).tpe.widen + val writerTpe = appliedType(weakTypeOf[tethys.JsonWriter[_]].typeConstructor, tpe) + val writer = c.inferImplicitValue(writerTpe) + if (writer.isEmpty) + c.abort(arg.pos, s"No JsonWriter[$tpe] in scope for interpolated value") + q"new _root_.tethys.JsonWriterOps[$tpe]($arg).asJsonWith($writer)($producerTree)" + } + + def renderKey(arg: Tree): Tree = { + val tpe = c.typecheck(arg.duplicate, silent = false).tpe.widen + val keyWriterTpe = appliedType(weakTypeOf[tethys.writers.KeyWriter[_]].typeConstructor, tpe) + val keyWriter = c.inferImplicitValue(keyWriterTpe) + if (keyWriter.isEmpty) + c.abort(arg.pos, s"No KeyWriter[$tpe] in scope for interpolated object key") + val stringWriter = c.inferImplicitValue(typeOf[tethys.JsonWriter[String]]) + if (stringWriter.isEmpty) + c.abort(c.enclosingPosition, "No JsonWriter[String] in scope (should be provided by tethys core)") + q"new _root_.tethys.JsonWriterOps[_root_.scala.Predef.String]($keyWriter.toKey($arg)).asJsonWith($stringWriter)($producerTree)" + } + + val argSnippets: IndexedSeq[Tree] = + argTrees.zipWithIndex.toIndexedSeq.map { case (argT, i) => + positionByIndex.getOrElse( + i, + c.abort(argT.pos, s"Internal: no resolved position for arg #$i") + ) match { + case HolePosition.Value => renderValue(argT) + case HolePosition.Key => renderKey(argT) + } + } + + val pieces: List[Tree] = + parts.zipWithIndex.flatMap { case (p, i) => + val lit: Tree = Literal(Constant(p)) + if (i < argSnippets.length) List(lit, argSnippets(i)) else List(lit) + } + + val concat: Tree = pieces match { + case Nil => Literal(Constant("")) + case h :: tail => tail.foldLeft(h)((acc, e) => q"$acc + $e") + } + + c.Expr[RawJson](q"_root_.tethys.commons.RawJson($concat)") + } +} diff --git a/modules/literal/src/main/scala-3/tethys/literal/JsonInterpolator.scala b/modules/literal/src/main/scala-3/tethys/literal/JsonInterpolator.scala new file mode 100644 index 00000000..7291c890 --- /dev/null +++ b/modules/literal/src/main/scala-3/tethys/literal/JsonInterpolator.scala @@ -0,0 +1,145 @@ +package tethys.literal + +import tethys.{JsonWriter, JsonWriterOps} +import tethys.commons.RawJson +import tethys.literal.JsonLiteralParser.HolePosition +import tethys.writers.KeyWriter +import tethys.writers.tokens.TokenWriterProducer + +import scala.quoted.* + +extension (inline sc: StringContext) + inline def json(inline args: Any*): RawJson = + ${ JsonInterpolatorMacro.impl('sc, 'args) } + +private[literal] object JsonInterpolatorMacro { + + def impl(sc: Expr[StringContext], args: Expr[Seq[Any]])(using + Quotes + ): Expr[RawJson] = { + import quotes.reflect.* + + val parts: List[String] = sc match { + case '{ StringContext(${ Varargs(rawParts) }*) } => + rawParts.toList.map { + case Expr(s: String) => s + case other => + report.errorAndAbort( + "json interpolator requires constant string parts", + other + ) + } + case _ => + report.errorAndAbort("Unexpected StringContext shape", sc) + } + + val argExprs: List[Expr[Any]] = args match { + case Varargs(xs) => xs.toList + case _ => + report.errorAndAbort( + "Expected varargs for json interpolator args", + args + ) + } + + if (parts.length != argExprs.length + 1) + report.errorAndAbort( + s"Malformed StringContext: ${parts.length} parts, ${argExprs.length} args" + ) + + val badIdx = JsonLiteralParser.findHoleInString(parts) + if (badIdx >= 0) + report.errorAndAbort( + "Interpolation inside a string literal is not supported — move the placeholder into a value or key position", + argExprs(badIdx) + ) + + val template = JsonLiteralParser.assembleTemplate(parts) + val holes = JsonLiteralParser.parse(template, argExprs.size) match { + case Right(hs) => hs + case Left(err) => + report.errorAndAbort( + s"Invalid JSON literal (at offset ${err.offset}): ${err.message}", + sc + ) + } + + if (argExprs.nonEmpty && Expr.summon[TokenWriterProducer].isEmpty) + report.errorAndAbort( + "No implicit TokenWriterProducer in scope. Did you forget `import tethys.jackson.*`?", + sc + ) + + val positionByIndex: Map[Int, HolePosition] = + holes.iterator.map(h => h.index -> h.position).toMap + + val argSnippets: IndexedSeq[Expr[String]] = + argExprs.zipWithIndex.toIndexedSeq.map { case (argE, i) => + positionByIndex.getOrElse( + i, + report + .errorAndAbort(s"Internal: no resolved position for arg #$i", argE) + ) match { + case HolePosition.Value => renderValue(argE) + case HolePosition.Key => renderKey(argE) + } + } + + val pieces: List[Expr[String]] = + parts.zipWithIndex.flatMap { case (p, i) => + val lit: Expr[String] = Expr(p) + if (i < argSnippets.length) List(lit, argSnippets(i)) else List(lit) + } + + val concat: Expr[String] = pieces match { + case Nil => Expr("") + case h :: tail => tail.foldLeft(h)((acc, e) => '{ $acc + $e }) + } + + '{ RawJson($concat) } + } + + private def renderValue(using Quotes)(arg: Expr[Any]): Expr[String] = { + import quotes.reflect.* + arg.asTerm.tpe.widen.asType match { + case '[t] => + Expr.summon[JsonWriter[t]] match { + case Some(w) => + val typedArg = arg.asExprOf[t] + '{ + ${ typedArg }.asJsonWith($w)(using + scala.compiletime.summonInline[TokenWriterProducer] + ) + } + case None => + report.errorAndAbort( + s"No JsonWriter[${Type.show[t]}] in scope for interpolated value", + arg + ) + } + } + } + + private def renderKey(using Quotes)(arg: Expr[Any]): Expr[String] = { + import quotes.reflect.* + arg.asTerm.tpe.widen.asType match { + case '[t] => + Expr.summon[KeyWriter[t]] match { + case Some(kw) => + val typedArg = arg.asExprOf[t] + '{ + ${ kw } + .toKey(${ typedArg }) + .asJsonWith(summon[JsonWriter[String]])(using + scala.compiletime.summonInline[TokenWriterProducer] + ) + } + case None => + report.errorAndAbort( + s"No KeyWriter[${Type.show[t]}] in scope for interpolated object key", + arg + ) + } + } + } +} diff --git a/modules/literal/src/main/scala/tethys/literal/JsonLiteralParser.scala b/modules/literal/src/main/scala/tethys/literal/JsonLiteralParser.scala new file mode 100644 index 00000000..501e94d2 --- /dev/null +++ b/modules/literal/src/main/scala/tethys/literal/JsonLiteralParser.scala @@ -0,0 +1,117 @@ +package tethys.literal + +import com.fasterxml.jackson.core.{JsonFactory, JsonParseException, JsonToken} + +import scala.collection.mutable.ListBuffer + +private[literal] object JsonLiteralParser { + + sealed trait HolePosition + object HolePosition { + case object Value extends HolePosition + case object Key extends HolePosition + } + + final case class Hole(index: Int, position: HolePosition) + final case class ParseError(message: String, offset: Int) + + final case class Template( + text: String, + markerPattern: scala.util.matching.Regex + ) + + def findHoleInString(parts: List[String]): Int = { + var i = 0 + while (i < parts.length - 1) { + var inString = false + var escaped = false + val s = parts(i) + var j = 0 + while (j < s.length) { + val c = s(j) + if (escaped) escaped = false + else if (c == '\\') escaped = true + else if (c == '"') inString = !inString + j += 1 + } + if (inString) return i + i += 1 + } + -1 + } + + def assembleTemplate(parts: List[String]): Template = { + val uuid = java.util.UUID.randomUUID().toString.replace("-", "") + val prefix = s"__TETHYS_HOLE_${uuid}_" + val suffix = "__" + val sb = new StringBuilder + var i = 0 + while (i < parts.length) { + sb.append(parts(i)) + if (i < parts.length - 1) { + sb.append('"') + sb.append(prefix) + sb.append(i) + sb.append(suffix) + sb.append('"') + } + i += 1 + } + val pattern = + (java.util.regex.Pattern.quote( + prefix + ) + "(\\d+)" + java.util.regex.Pattern.quote(suffix)).r + Template(sb.toString, pattern) + } + + private val factory = new JsonFactory() + + def parse(tpl: Template, holeCount: Int): Either[ParseError, List[Hole]] = { + val template = tpl.text + val markerPattern = tpl.markerPattern + val parser = factory.createParser(template) + val holes = ListBuffer.empty[Hole] + try { + var sawToken = false + var t: JsonToken = parser.nextToken() + while (t != null) { + sawToken = true + t match { + case JsonToken.FIELD_NAME => + val name = parser.getCurrentName + markerPattern.findFirstMatchIn(name) match { + case Some(m) if m.matched == name => + holes += Hole(m.group(1).toInt, HolePosition.Key) + case _ => + } + case JsonToken.VALUE_STRING => + val text = parser.getText + markerPattern.findFirstMatchIn(text) match { + case Some(m) if m.matched == text => + holes += Hole(m.group(1).toInt, HolePosition.Value) + case _ => + } + case _ => + } + t = parser.nextToken() + } + if (!sawToken) + Left(ParseError("Unexpected end of input, expected a value", 0)) + else if (holes.size != holeCount) + Left( + ParseError( + s"Internal: parsed ${holes.size} holes, expected $holeCount", + 0 + ) + ) + else + Right(holes.toList) + } catch { + case e: JsonParseException => + val raw = Option(e.getOriginalMessage).getOrElse(e.getMessage) + Left(ParseError(raw, e.getLocation.getCharOffset.toInt)) + } finally { + parser.close() + } + } +} diff --git a/modules/literal/src/test/scala-2.13+/tethys/literal/JsonInterpolatorSpec.scala b/modules/literal/src/test/scala-2.13+/tethys/literal/JsonInterpolatorSpec.scala new file mode 100644 index 00000000..60210fa3 --- /dev/null +++ b/modules/literal/src/test/scala-2.13+/tethys/literal/JsonInterpolatorSpec.scala @@ -0,0 +1,111 @@ +package tethys.literal + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.immutable.ListMap + +import tethys._ +import tethys.commons.RawJson +import tethys.commons.TokenNode.TokenNodesOps +import tethys.derivation.semiauto._ +import tethys.jackson._ +import tethys.literal.JsonInterpolator._ + +class JsonInterpolatorSpec extends AnyFlatSpec with Matchers { + + private def sameJson(actual: String, expected: String): org.scalatest.Assertion = + actual.jsonAsTokensList shouldBe expected.jsonAsTokensList + + private def sameJson(actual: RawJson, expected: String): org.scalatest.Assertion = + sameJson(actual.json, expected) + + behavior of "json interpolator on Scala 2" + + it should "accept primitive literals" in { + sameJson(json"""5""", "5") + sameJson(json"""-3.14""", "-3.14") + sameJson(json"""true""", "true") + sameJson(json"""null""", "null") + sameJson(json""""hello"""", "\"hello\"") + sameJson(json"""{}""", "{}") + sameJson(json"""[]""", "[]") + } + + it should "accept a multiline literal with no holes" in { + val rj = json""" + { + "a": 1, + "b": [1, 2, 3], + "c": null + } + """ + sameJson(rj, """{"a": 1, "b": [1, 2, 3], "c": null}""") + } + + it should "interpolate primitives" in { + val n = 42 + val s = "hi \"there\"\n\\slash" + sameJson(json"""{"n": $n, "s": $s}""", """{"n": 42, "s": "hi \"there\"\n\\slash"}""") + } + + it should "interpolate collections and Map[String, Int] with deterministic order" in { + val xs = List(1, 2, 3) + val m: Map[String, Int] = ListMap("a" -> 1, "b" -> 2) + sameJson(json"""$xs""", "[1, 2, 3]") + sameJson(json"""$m""", """{"a": 1, "b": 2}""") + } + + case class User(name: String, age: Int) + object User { + implicit val jw: JsonObjectWriter[User] = jsonWriter[User] + } + + it should "interpolate a case class with a derived JsonObjectWriter" in { + val u = User("Alice", 30) + sameJson(json"""$u""", """{"name": "Alice", "age": 30}""") + sameJson(json"""[1, $u, true]""", """[1, {"name": "Alice", "age": 30}, true]""") + } + + it should "interpolate keys via KeyWriter" in { + val ks = "dynamic" + val ki = 7 + sameJson(json"""{$ks: 1}""", """{"dynamic": 1}""") + sameJson(json"""{$ki: "v"}""", """{"7": "v"}""") + } + + it should "splice a RawJson value without re-quoting it" in { + val inner = json"""{"inner": [1, 2]}""" + sameJson(json"""{"outer": $inner}""", """{"outer": {"inner": [1, 2]}}""") + } + + it should "round-trip arbitrary string content" in { + val controls = (0 until 32).map(_.toChar).mkString + val unicode = "привет, мир \u0301" + json"""$controls""".json.jsonAs[String] shouldBe Right(controls) + json"""$unicode""".json.jsonAs[String] shouldBe Right(unicode) + } + + it should "assemble a deeply nested document mixing literals, primitives, collections and a case class hole" in { + val u = User("Alice", 30) + val m: Map[String, Int] = ListMap("maxConn" -> 100, "timeout" -> 30) + val tags = List("scala", "json") + val rj = json""" + { + "user": $u, + "tags": $tags, + "limits": $m, + "meta": { "active": true, "retries": null } + } + """ + sameJson( + rj, + """{ + | "user": {"name": "Alice", "age": 30}, + | "tags": ["scala", "json"], + | "limits": {"maxConn": 100, "timeout": 30}, + | "meta": {"active": true, "retries": null} + |}""".stripMargin + ) + } +} diff --git a/modules/literal/src/test/scala-3/tethys/literal/JsonInterpolatorSpec.scala b/modules/literal/src/test/scala-3/tethys/literal/JsonInterpolatorSpec.scala new file mode 100644 index 00000000..504bbf63 --- /dev/null +++ b/modules/literal/src/test/scala-3/tethys/literal/JsonInterpolatorSpec.scala @@ -0,0 +1,373 @@ +package tethys.literal + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.immutable.ListMap + +import io.circe.Json + +import tethys.* +import tethys.commons.RawJson +import tethys.commons.TokenNode.TokenNodesOps +import tethys.jackson.* +import tethys.literal.* +import tethys.writers.tokens.TokenWriterProducer + +given explicitProducer: TokenWriterProducer = jacksonTokenWriterProducer + +class JsonInterpolatorSpec extends AnyFlatSpec with Matchers { + + private def sameJson( + actual: String, + expected: String + ): org.scalatest.Assertion = + actual.jsonAsTokensList shouldBe expected.jsonAsTokensList + + private def sameJson( + actual: RawJson, + expected: String + ): org.scalatest.Assertion = + sameJson(actual.json, expected) + + private def sameJson( + actual: RawJson, + expected: Json + ): org.scalatest.Assertion = + sameJson(actual.json, expected.noSpaces) + + behavior of "json interpolator — primitives & null" + + it should "accept every JSON scalar form" in { + sameJson(json"""5""", "5") + sameJson(json"""-17""", "-17") + sameJson(json"""0""", "0") + sameJson(json"""3.14""", "3.14") + sameJson(json"""1.5e10""", "1.5e10") + sameJson(json"""-2.5E-3""", "-2.5E-3") + sameJson(json"""6E+2""", "6E+2") + sameJson(json"""true""", "true") + sameJson(json"""false""", "false") + sameJson(json"""null""", "null") + sameJson(json""""hello"""", "\"hello\"") + sameJson(json""""\u00A0\u1234"""", "\"\\u00A0\\u1234\"") + sameJson( + json""""tab\there\nnewline\\slash\/"""", + "\"tab\\there\\nnewline\\\\slash\\/\"" + ) + } + + it should "accept empty composite literals" in { + sameJson(json"""{}""", "{}") + sameJson(json"""[]""", "[]") + sameJson(json"""[[]]""", "[[]]") + sameJson(json"""{"a": []}""", """{"a":[]}""") + } + + behavior of "json interpolator — multiline literals" + + it should "accept a pretty-printed multiline object" in { + val rj = json""" + { + "name": "Alice", + "age": 30, + "tags": ["scala", "json"], + "addr": { + "city": "Kazan", + "zip": "420000" + } + } + """ + sameJson( + rj, + """{ + | "name": "Alice", + | "age": 30, + | "tags": ["scala", "json"], + | "addr": {"city": "Kazan", "zip": "420000"} + |}""".stripMargin + ) + } + + it should "accept multiline arrays with comments-style spacing" in { + val rj = json""" + [ + 1, + 2, + + 3, + [ + "x", + "y" + ] + ] + """ + sameJson(rj, """[1, 2, 3, ["x", "y"]]""") + } + + it should "accept holes spread across multiple lines" in { + val (name, age, city) = ("Bob", 25, "Innopolis") + val rj = json""" + { + "name": $name, + "age": $age, + "city": $city + } + """ + sameJson(rj, """{"name": "Bob", "age": 25, "city": "Innopolis"}""") + } + + behavior of "json interpolator — value holes" + + it should "interpolate primitives with correct JSON encoding" in { + val n: Int = 42 + val l: Long = 9000000000L + val d: Double = 2.5 + val b: Boolean = true + val s: String = "hi \"there\"\n\\slash" + val none: Option[Int] = None + val some: Option[Int] = Some(7) + + sameJson( + json"""{ + "n": $n, "l": $l, "d": $d, "b": $b, + "s": $s, "none": $none, "some": $some + }""", + """{ + | "n": 42, "l": 9000000000, "d": 2.5, "b": true, + | "s": "hi \"there\"\n\\slash", "none": null, "some": 7 + |}""".stripMargin + ) + } + + it should "interpolate collections in array and object positions" in { + val xs = List(1, 2, 3) + val nest = List(List(1, 2), List(3, 4, 5)) + val ss = Seq("a", "b\"c", "d\nE") + + sameJson( + json"""{ "xs": $xs, "nest": $nest, "ss": $ss }""", + """{ "xs": [1, 2, 3], "nest": [[1, 2], [3, 4, 5]], "ss": ["a", "b\"c", "d\nE"] }""" + ) + } + + it should "interpolate Map[String, Int] preserving insertion order with ListMap" in { + val m: Map[String, Int] = ListMap("a" -> 1, "b" -> 2, "c" -> 3) + sameJson(json"""$m""", """{"a": 1, "b": 2, "c": 3}""") + } + + case class User(name: String, age: Int) derives JsonObjectWriter + case class Endpoint(path: String, methods: List[String]) + derives JsonObjectWriter + + it should "interpolate user case classes (single & inside structures)" in { + val u = User("Alice", 30) + val ep = Endpoint("/api/users", List("GET", "POST")) + + sameJson(json"""$u""", """{"name": "Alice", "age": 30}""") + sameJson( + json"""[1, $u, true]""", + """[1, {"name": "Alice", "age": 30}, true]""" + ) + sameJson( + json"""{ "user": $u, "endpoint": $ep }""", + """{ + | "user": {"name": "Alice", "age": 30}, + | "endpoint": {"path": "/api/users", "methods": ["GET", "POST"]} + |}""".stripMargin + ) + } + + it should "splice a RawJson value without re-quoting it" in { + val inner = json"""{"inner": [1, 2]}""" + sameJson( + json"""{"outer": $inner, "tail": null}""", + """{"outer": {"inner": [1, 2]}, "tail": null}""" + ) + } + + behavior of "json interpolator — key holes" + + it should "interpolate keys of various types via KeyWriter" in { + val ks = "dynamic" + val ki = 7 + val ku = java.util.UUID.fromString("00000000-0000-0000-0000-000000000001") + val kEs = "a\"b\nc" + + sameJson(json"""{$ks: 1}""", """{"dynamic": 1}""") + sameJson(json"""{$ki: "v"}""", """{"7": "v"}""") + sameJson( + json"""{$ku: null}""", + """{"00000000-0000-0000-0000-000000000001": null}""" + ) + sameJson(json"""{$kEs: 1}""", """{"a\"b\nc": 1}""") + } + + behavior of "json interpolator — heavy structural cases" + + case class Server( + host: String, + port: Int, + ssl: Boolean, + endpoints: List[Endpoint] + ) derives JsonObjectWriter + + it should "match a deeply nested document built with circe AST, mixing literals, primitives, collections, Map and a case class hole" in { + val host = "localhost" + val port = 8080 + val timeout = 30 + val maxConn = 1000 + val features = List("auth", "logging") + val getMethods = List("GET") + val limits: Map[String, Int] = + ListMap("maxConnections" -> maxConn, "timeout" -> timeout) + val server = Server( + host = host, + port = port, + ssl = true, + endpoints = List( + Endpoint("/api/users", List("GET", "POST")), + Endpoint("/api/health", getMethods) + ) + ) + + val rj = json""" + { + "server": $server, + "config": { + "host": $host, + "port": $port, + "limits": $limits, + "endpoints": [ + { + "path": "/api/users", + "methods": ["GET", "POST"], + "headers": { + "Content-Type": "application/json", + "Accept": "application/json" + } + }, + { + "path": "/api/health", + "methods": $getMethods, + "headers": { "Accept": "text/plain" } + } + ], + "retries": null + }, + "features": $features + } + """ + + sameJson( + rj, + Json.obj( + "server" -> Json.obj( + "host" -> Json.fromString("localhost"), + "port" -> Json.fromInt(8080), + "ssl" -> Json.True, + "endpoints" -> Json.arr( + Json.obj( + "path" -> Json.fromString("/api/users"), + "methods" -> Json + .arr(Json.fromString("GET"), Json.fromString("POST")) + ), + Json.obj( + "path" -> Json.fromString("/api/health"), + "methods" -> Json.arr(Json.fromString("GET")) + ) + ) + ), + "config" -> Json.obj( + "host" -> Json.fromString("localhost"), + "port" -> Json.fromInt(8080), + "limits" -> Json.obj( + "maxConnections" -> Json.fromInt(1000), + "timeout" -> Json.fromInt(30) + ), + "endpoints" -> Json.arr( + Json.obj( + "path" -> Json.fromString("/api/users"), + "methods" -> Json + .arr(Json.fromString("GET"), Json.fromString("POST")), + "headers" -> Json.obj( + "Content-Type" -> Json.fromString("application/json"), + "Accept" -> Json.fromString("application/json") + ) + ), + Json.obj( + "path" -> Json.fromString("/api/health"), + "methods" -> Json.arr(Json.fromString("GET")), + "headers" -> Json.obj( + "Accept" -> Json.fromString("text/plain") + ) + ) + ), + "retries" -> Json.Null + ), + "features" -> Json.arr( + Json.fromString("auth"), + Json.fromString("logging") + ) + ) + ) + } + + it should "round-trip arbitrary string content through interpolation" in { + val controls = (0 until 32).map(_.toChar).mkString + val unicode = "привет, \uD83C\uDF0D и спецсимвол \u0301" + val mixed = controls + "==" + unicode + + json"""$controls""".json.jsonAs[String] shouldBe Right(controls) + json"""$unicode""".json.jsonAs[String] shouldBe Right(unicode) + json"""$mixed""".json.jsonAs[String] shouldBe Right(mixed) + } + + behavior of "json interpolator — compile-time validation" + + import scala.compiletime.testing.{typeCheckErrors, ErrorKind} + + inline val P = + "import tethys.*; import tethys.jackson.*; import tethys.literal.*; " + + private inline def rejectsWith( + inline code: String, + inline expectedMessage: String + ): org.scalatest.Assertion = { + val errs = typeCheckErrors(code) + val joined = errs.map(_.message).mkString("\n") + withClue(s"diagnostics:\n$joined\n") { + errs should not be empty + joined.toLowerCase should include(expectedMessage.toLowerCase) + } + } + + it should "reject malformed structures with a precise parser message" in { + rejectsWith(P + """json"42 oops"""", "unrecognized token") + rejectsWith(P + """json"[1,]"""", "expected a valid value") + rejectsWith(P + """json""""", "unexpected end") + rejectsWith(P + """json"]"""", "unexpected close marker") + } + + it should "reject malformed numbers with a precise parser message" in { + rejectsWith(P + """json"-"""", "no digit following sign") + rejectsWith(P + """json"01"""", "leading zeroes not allowed") + rejectsWith(P + """json"1."""", "decimal point not followed by a digit") + rejectsWith(P + """json"1e"""", "digit for number exponent") + } + + it should "reject a value hole whose type has no JsonWriter and name the type" in { + rejectsWith( + P + """class NoWriter; val x = new NoWriter; json"${x}"""", + "no jsonwriter[nowriter]" + ) + } + + it should "reject a key hole whose type has no KeyWriter and name the type" in { + rejectsWith( + P + """class NoKey; val k = new NoKey; json"{${k}: 1}"""", + "no keywriter[nokey]" + ) + } + +} diff --git a/modules/literal/src/test/scala/tethys/literal/JsonLiteralParserSpec.scala b/modules/literal/src/test/scala/tethys/literal/JsonLiteralParserSpec.scala new file mode 100644 index 00000000..2cfb0d3c --- /dev/null +++ b/modules/literal/src/test/scala/tethys/literal/JsonLiteralParserSpec.scala @@ -0,0 +1,156 @@ +package tethys.literal + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import tethys.literal.JsonLiteralParser.{Hole, HolePosition, ParseError} + +class JsonLiteralParserSpec extends AnyFlatSpec with Matchers { + + private def parse(parts: String*): Either[ParseError, List[Hole]] = { + val partsList = parts.toList + val badIdx = JsonLiteralParser.findHoleInString(partsList) + if (badIdx >= 0) + Left( + ParseError( + "Interpolation inside a string literal is not supported", + badIdx + ) + ) + else { + val template = JsonLiteralParser.assembleTemplate(partsList) + JsonLiteralParser.parse(template, parts.length - 1) + } + } + + private def ok(parts: String*)(expected: Hole*): Unit = + parse(parts: _*) shouldBe Right(expected.toList) + + private def rejected( + parts: String* + )(messageSubstring: String): Unit = { + val res = parse(parts: _*) + res.isLeft shouldBe true + val err = res.swap.toOption.get + withClue(s"message was: ${err.message}") { + err.message.toLowerCase should include(messageSubstring.toLowerCase) + } + } + + behavior of "JsonLiteralParser — primitives" + + it should "accept bare strings" in { ok("\"hello\"")() } + it should "accept escaped strings" in { + ok("\"a\\nb\\t\\\"c\\\\d\\/e\\b\\f\\r\"")() + } + it should "accept unicode escapes" in { ok("\"\\u00A0\\u1234\"")() } + it should "accept integer" in { ok("42")() } + it should "accept negative integer" in { ok("-17")() } + it should "accept zero" in { ok("0")() } + it should "accept decimal" in { ok("3.14")() } + it should "accept scientific" in { + ok("1.5e10")(); ok("-2.5E-3")(); ok("6E+2")() + } + it should "accept true/false/null" in { + ok("true")(); ok("false")(); ok("null")() + } + + behavior of "JsonLiteralParser — structural" + + it should "accept empty object" in { ok("{}")() } + it should "accept empty array" in { ok("[]")() } + it should "accept object with one field" in { ok("""{"a": 1}""")() } + it should "accept nested object" in { + ok("""{"a": {"b": {"c": {"d": 42}}}}""")() + } + it should "accept array of mixed types" in { + ok("""[1, "s", true, null, 3.14, {"k": "v"}, []]""")() + } + it should "accept whitespace everywhere" in { + ok(" \n\t { \"a\" : [ 1 , 2 ] , \"b\" : null } \n")() + } + + behavior of "JsonLiteralParser — holes" + + it should "accept a single value hole" in { + ok("", "")(Hole(0, HolePosition.Value)) + } + it should "accept a value hole inside an object" in { + ok("""{"a": """, "}")(Hole(0, HolePosition.Value)) + } + it should "accept multiple value holes inside an array" in { + ok("[", ", ", ", ", "]")( + Hole(0, HolePosition.Value), + Hole(1, HolePosition.Value), + Hole(2, HolePosition.Value) + ) + } + it should "accept a key hole" in { + ok("{", """: "v"}""")(Hole(0, HolePosition.Key)) + } + it should "accept both key and value holes" in { + ok("{", ": ", "}")( + Hole(0, HolePosition.Key), + Hole(1, HolePosition.Value) + ) + } + it should "accept deeply nested holes" in { + ok("""{"a": [1, {"b": """, """}, 3], "c": """, "}")( + Hole(0, HolePosition.Value), + Hole(1, HolePosition.Value) + ) + } + + behavior of "JsonLiteralParser — rejections" + + it should "reject trailing garbage" in { + rejected("42 oops")("unrecognized token") + } + it should "reject unterminated string" in { + rejected("\"abc")("expecting closing quote") + } + it should "reject invalid escape" in { + rejected("\"\\q\"")("unrecognized character escape") + } + it should "reject truncated unicode escape" in { + rejected("\"\\u12\"")("hex-digit") + } + it should "reject missing colon" in { + rejected("""{"a" 1}""")("expecting a colon") + } + it should "reject missing comma" in { + rejected("""{"a": 1 "b": 2}""")("expecting comma") + } + it should "reject dangling comma in object" in { + rejected("""{"a": 1,}""")("expecting double-quote to start field name") + } + it should "reject dangling comma in array" in { + rejected("[1,]")("expected a valid value") + } + it should "reject lone minus" in { rejected("-")("no digit following sign") } + it should "reject number with leading zero" in { + rejected("01")("leading zeroes not allowed") + } + it should "reject number with missing fraction digits" in { + rejected("1.")("decimal point not followed by a digit") + } + it should "reject number with missing exponent digits" in { + rejected("1e")("digit for number exponent") + } + it should "reject bare identifier" in { + rejected("foo")("unrecognized token") + } + it should "reject hole inside a string" in { + rejected("\"hello ", "\"")("inside a string literal is not supported") + } + it should "reject empty input" in { rejected("")("unexpected end") } + it should "reject only whitespace" in { + rejected(" \n ")("unexpected end") + } + it should "reject closing without opening" in { + rejected("]")("unexpected close marker") + } + it should "reject unbalanced braces" in { + rejected("""{"a": 1""")("expected close marker") + } +}