diff --git a/docs/content/reference/named-tuples.md b/docs/content/reference/named-tuples.md index 9a31d3b..004dae2 100644 --- a/docs/content/reference/named-tuples.md +++ b/docs/content/reference/named-tuples.md @@ -23,3 +23,13 @@ val str = """ { "name": "Mujo", "age": 35 } """ val nt2 = str.parseJson[Person] // (name = "Mujo", age = 35) ``` + +Singleton literal members are supported too: + +```scala +type Marker = (kind: "abc") + +val marker: Marker = (kind = "abc") +assert(marker.toJson(spaces = 0) == """{"kind":"abc"}""") +assert("""{"kind":"abc"}""".parseJson[Marker] == marker) +``` diff --git a/tupson/src/ba/sake/tupson/instances.scala b/tupson/src/ba/sake/tupson/instances.scala index a45e040..a1f4765 100644 --- a/tupson/src/ba/sake/tupson/instances.scala +++ b/tupson/src/ba/sake/tupson/instances.scala @@ -2,6 +2,7 @@ package ba.sake.tupson import scala.collection.mutable.ArrayDeque import scala.compiletime.summonInline +import scala.compiletime.summonFrom import NamedTuple.AnyNamedTuple import NamedTuple.Names import NamedTuple.DropNames @@ -15,6 +16,58 @@ import org.typelevel.jawn.ast.* private[tupson] trait LowPriorityJsonRWInstances { + inline given autoderiveLiteral[ + T <: String | Char | Boolean | Int | Long | Float | Double + ](using valueOf: ValueOf[T]): JsonRW[T] = + summonFrom { + case ev: (T <:< String) => literalRW[T, String](using valueOf, ev, summonInline[JsonRW[String]]) + case ev: (T <:< Char) => literalCharRW[T](using valueOf, ev) + case ev: (T <:< Boolean) => literalRW[T, Boolean](using valueOf, ev, summonInline[JsonRW[Boolean]]) + case ev: (T <:< Int) => literalRW[T, Int](using valueOf, ev, summonInline[JsonRW[Int]]) + case ev: (T <:< Long) => literalRW[T, Long](using valueOf, ev, summonInline[JsonRW[Long]]) + case ev: (T <:< Float) => literalRW[T, Float](using valueOf, ev, summonInline[JsonRW[Float]]) + case ev: (T <:< Double) => literalRW[T, Double](using valueOf, ev, summonInline[JsonRW[Double]]) + } + + private inline def literalRW[T, Wide](using + valueOf: ValueOf[T], + ev: T <:< Wide, + rw: JsonRW[Wide] + ): JsonRW[T] = + new JsonRW[T] { + private val expectedValue = valueOf.value + private val expectedMsg = s"should be literal value '$expectedValue'" + + override def write(value: T): JValue = rw.write(ev(value)) + + override def parse(path: String, jValue: JValue): T = + val parsed = rw.parse(path, jValue) + if parsed == expectedValue then expectedValue + else throw ParsingException(ParseError(path, expectedMsg, Some(parsed))) + } + + private inline def literalCharRW[T](using + valueOf: ValueOf[T], + ev: T <:< Char + ): JsonRW[T] = + new JsonRW[T] { + private val expectedValue = valueOf.value + private val expectedChar = ev(expectedValue) + private val expectedMsg = s"should be literal value '$expectedValue'" + + override def write(value: T): JValue = JsonRW[Char].write(ev(value)) + + override def parse(path: String, jValue: JValue): T = + jValue match + case JString(s) if s.length == 1 => + if s.head == expectedChar then expectedValue + else throw ParsingException(ParseError(path, expectedMsg, Some(s.head))) + case JString(s) => + throw ParsingException(ParseError(path, expectedMsg, Some(s))) + case other => + JsonRW.typeMismatchError(path, "Char", other) + } + inline given autoderiveNamedTuple[T <: AnyNamedTuple](using T <:< AnyNamedTuple): JsonRW[T] = { val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq val fieldJsonRWs = diff --git a/tupson/test/src/ba/sake/tupson/ParseSuite.scala b/tupson/test/src/ba/sake/tupson/ParseSuite.scala index fc3e447..d7fb780 100644 --- a/tupson/test/src/ba/sake/tupson/ParseSuite.scala +++ b/tupson/test/src/ba/sake/tupson/ParseSuite.scala @@ -304,6 +304,64 @@ class ParseSuite extends munit.FunSuite { assertEquals(nt2, (name = "Mujo", age = 35)) } + test("parse literal types") { + assertEquals("""{"x":"abc"}""".parseJson[LiteralPerson], (x = "abc")) + assertEquals( + """{"x":"abc"}""".parseJson[LiteralStringCaseClass], + LiteralStringCaseClass("abc") + ) + assertEquals("""{"x":123}""".parseJson[LiteralIntCaseClass], LiteralIntCaseClass(123)) + assertEquals( + """{"x":true}""".parseJson[LiteralBooleanCaseClass], + LiteralBooleanCaseClass(true) + ) + assertEquals("""{"x":"a"}""".parseJson[LiteralCharCaseClass], LiteralCharCaseClass('a')) + + assertEquals(""" "abc" """.parseJson[LiteralUnion], "abc") + assertEquals(""" 123 """.parseJson[LiteralUnion], 123) + assertEquals(""" true """.parseJson[LiteralUnion], true) + + val ex = intercept[ParsingException] { + """{"x":"def"}""".parseJson[LiteralStringCaseClass] + } + assertEquals( + ex.errors, + Seq( + ParseError("$.x", "should be literal value 'abc'", Some("def")) + ) + ) + + val tupleEx = intercept[ParsingException] { + """{"x":"def"}""".parseJson[LiteralPerson] + } + assertEquals( + tupleEx.errors, + Seq( + ParseError("$.x", "should be literal value 'abc'", Some("def")) + ) + ) + + val charEx = intercept[ParsingException] { + """{"x":"ab"}""".parseJson[LiteralCharCaseClass] + } + assertEquals( + charEx.errors, + Seq( + ParseError("$.x", "should be literal value 'a'", Some("ab")) + ) + ) + + val unionEx = intercept[ParsingException] { + """ false """.parseJson[LiteralUnion] + } + assertEquals( + unionEx.errors, + Seq( + ParseError("$", "should be literal value 'true'", Some(false)) + ) + ) + } + /* union type */ test("parse union type") { // import unionTypes.given diff --git a/tupson/test/src/ba/sake/tupson/WriteSuite.scala b/tupson/test/src/ba/sake/tupson/WriteSuite.scala index 0f407c8..3a38a41 100644 --- a/tupson/test/src/ba/sake/tupson/WriteSuite.scala +++ b/tupson/test/src/ba/sake/tupson/WriteSuite.scala @@ -197,6 +197,29 @@ class WriteSuite extends munit.FunSuite { ) } + test("write literal types") { + val nt: LiteralPerson = (x = "abc") + assertEquals(nt.toJson(spaces = 0, sort = true), """{"x":"abc"}""") + + assertEquals( + LiteralStringCaseClass("abc").toJson(spaces = 0, sort = true), + """{"x":"abc"}""" + ) + assertEquals(LiteralIntCaseClass(123).toJson(spaces = 0, sort = true), """{"x":123}""") + assertEquals( + LiteralBooleanCaseClass(true).toJson(spaces = 0, sort = true), + """{"x":true}""" + ) + assertEquals(LiteralCharCaseClass('a').toJson(spaces = 0, sort = true), """{"x":"a"}""") + + val unionString: LiteralUnion = "abc" + assertEquals(unionString.toJson(spaces = 0, sort = true), """"abc"""") + val unionInt: LiteralUnion = 123 + assertEquals(unionInt.toJson(spaces = 0, sort = true), "123") + val unionBool: LiteralUnion = true + assertEquals(unionBool.toJson(spaces = 0, sort = true), "true") + } + test("write default spaced output") { assertEquals( CaseClass1("str", 123).toJson, diff --git a/tupson/test/src/ba/sake/tupson/types.scala b/tupson/test/src/ba/sake/tupson/types.scala index e24c4c4..1acd471 100644 --- a/tupson/test/src/ba/sake/tupson/types.scala +++ b/tupson/test/src/ba/sake/tupson/types.scala @@ -13,6 +13,10 @@ case class CaseClassDefault( // parsed as Some("default") IF THEY KEY IS MISSING (not failing) str: Option[String] = Some("default") ) derives JsonRW +case class LiteralStringCaseClass(x: "abc") derives JsonRW +case class LiteralIntCaseClass(x: 123) derives JsonRW +case class LiteralBooleanCaseClass(x: true) derives JsonRW +case class LiteralCharCaseClass(x: 'a') derives JsonRW package rec { case class Node(children: Seq[Node]) derives JsonRW @@ -23,3 +27,5 @@ package weird_named { } type Person = (name: String, age: Int) +type LiteralPerson = (x: "abc") +type LiteralUnion = "abc" | 123 | true