Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }"""
```
18 changes: 17 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ lazy val tethys = project
circe,
refined,
enumeratum,
cats
cats,
literal
)

lazy val modules = file("modules")
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion modules/core/src/main/scala/tethys/commons/TokenNode.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)")
}
}
145 changes: 145 additions & 0 deletions modules/literal/src/main/scala-3/tethys/literal/JsonInterpolator.scala
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
}
Loading