diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 6f67d78118..393238e018 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -179,6 +179,7 @@ jobs: - name: Install scala if: ${{ contains(matrix.fixture, 'scala3') }} uses: VirtusLab/scala-cli-setup@main + - run: echo '@main def hello() = println("We need this spam print statement for bloop to exit correctly...")' | scala-cli _ if: ${{ contains(matrix.fixture, 'scala3') }} diff --git a/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts b/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts index 348b585ad8..5b122234d6 100644 --- a/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts +++ b/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts @@ -15,7 +15,7 @@ import { } from "../../Type/TypeUtils"; import { Scala3Renderer } from "./Scala3Renderer"; -import { wrapOption } from "./utils"; +import { shouldAddBacktick, wrapOption } from "./utils"; export class CirceRenderer extends Scala3Renderer { private seenUnionTypes: string[] = []; @@ -55,7 +55,13 @@ export class CirceRenderer extends Scala3Renderer { paramName, ")", ], - (_) => ["Encoder.encodeString(", paramName, ")"], + (enumType) => [ + "summon[Encoder[", + this.scalaType(enumType), + "]].apply(", + paramName, + ")", + ], (unionType) => { const nullable = nullableFromUnion(unionType); if (nullable !== null) { @@ -104,24 +110,127 @@ export class CirceRenderer extends Scala3Renderer { protected emitEnumDefinition(e: EnumType, enumName: Name): void { this.emitDescription(this.descriptionForType(e)); - this.ensureBlankLine(); - this.emitItem(["type ", enumName, " = "]); - let count = e.cases.size; + let hasBlank = false; this.forEachEnumCase(e, "none", (_, jsonName) => { - // if (!(jsonName == "")) { - /* const backticks = - shouldAddBacktick(jsonName) || - jsonName.includes(" ") || - !isNaN(parseInt(jsonName.charAt(0))) - if (backticks) {this.emitItem("`")} else */ - this.emitItem(['"', jsonName, '"']); - // if (backticks) {this.emitItem("`")} - if (--count > 0) this.emitItem([" | "]); - // } else { - // --count - // } + if (jsonName.trim() === "") { + hasBlank = true; + } }); this.ensureBlankLine(); + + const isBlank = (str: string): boolean => !str.trim(); + + if (hasBlank) { + //console.log("enumName: " + enumName + " has blank"); + this.emitItem(["type ", enumName, ' = "" | ', enumName, "NonBlank"]); + this.ensureBlankLine(); + this.emitLine([ + "given ", + enumName, + "Enc: Encoder[", + enumName, + "] = Encoder.encodeString.contramap(_.toString())", + ]); + this.emitLine([ + "given ", + enumName, + "Dec: Decoder[", + enumName, + "] = List[Decoder[", + enumName, + "]](", + ]); + this.indent(() => { + this.emitLine('Decoder[""].widen,'); + this.emitLine(["Decoder[", enumName, "NonBlank].widen"]); + }); + this.emitLine(").reduceLeft(_ or _)"); + + this.ensureBlankLine(); + //let count = e.cases.size; + this.ensureBlankLine(); + this.emitLine(["enum ", enumName, "NonBlank :"]); + this.indent(() => { + //let count = e.cases.size; + + this.forEachEnumCase(e, "none", (_, jsonName) => { + let strBuild = ""; + const backticks = + shouldAddBacktick(jsonName) || + jsonName.includes(" ") || + !Number.isNaN(Number.parseInt(jsonName.charAt(0))); + if (!isBlank(jsonName)) { + this.emitItem(["case "]); + } + + if (backticks) { + strBuild = strBuild + "`"; + } + + strBuild = strBuild + jsonName; + if (backticks) { + strBuild = strBuild + "`"; + } + + //if (--count > 0) strBuild + ","; + // don't emit the blank case + if (!isBlank(jsonName)) { + this.emitLine([strBuild]); + } + }); + }); + + this.emitLine([ + "given Decoder[", + enumName, + "NonBlank] = Decoder.decodeString.emapTry(x => Try(", + enumName, + "NonBlank.valueOf(x) ))", + ]); + this.emitLine( + "given Encoder[", + enumName, + "NonBlank] = Encoder.encodeString.contramap(_.toString())", + ); + } else { + //console.log("enumName: " + enumName + " has non blank"); + this.emitLine(["enum ", enumName, " : "]); + + this.indent(() => { + this.forEachEnumCase(e, "none", (_, jsonName) => { + let strBuild = ""; + const backticks = + shouldAddBacktick(jsonName) || + jsonName.includes(" ") || + !Number.isNaN(Number.parseInt(jsonName.charAt(0))); + this.emitItem(["case "]); + if (backticks) { + strBuild = strBuild + "`"; + } + + strBuild = strBuild + jsonName; + if (backticks) { + strBuild = strBuild + "`"; + } + + // if (--count > 0) strBuild + ","; + this.emitLine([strBuild]); + }); + }); + this.emitLine([ + "given Decoder[", + enumName, + "] = Decoder.decodeString.emapTry(x => Try(", + enumName, + ".valueOf(x) )) ", + ]); + this.emitLine([ + "given Encoder[", + enumName, + "] = Encoder.encodeString.contramap(_.toString())", + ]); + this.ensureBlankLine(); + } } protected emitHeader(): void { @@ -134,12 +243,12 @@ export class CirceRenderer extends Scala3Renderer { this.ensureBlankLine(); this.emitLine("// For serialising string unions"); - this.emitLine( - "given [A <: Singleton](using A <:< String): Decoder[A] = Decoder.decodeString.emapTry(x => Try(x.asInstanceOf[A])) ", - ); - this.emitLine( - "given [A <: Singleton](using ev: A <:< String): Encoder[A] = Encoder.encodeString.contramap(ev) ", - ); + // this.emitLine( + // "given [A <: Singleton](using A <:< String): Decoder[A] = Decoder.decodeString.emapTry(x => Try(x.asInstanceOf[A])) " + // ); + // this.emitLine( + // "given [A <: Singleton](using ev: A <:< String): Encoder[A] = Encoder.encodeString.contramap(ev) " + // ); this.ensureBlankLine(); this.emitLine( "// If a union has a null in, then we'll need this too... ", @@ -153,9 +262,9 @@ export class CirceRenderer extends Scala3Renderer { this.emitLine([ "given (using ev : ", elementType, - "): Encoder[Map[String,", + "): Encoder[Seq[", elementType, - "]] = Encoder.encodeMap[String, ", + "]] = Encoder.encodeSeq[", elementType, "]", ]); diff --git a/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts b/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts index 913fd61490..d02da70e9e 100644 --- a/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts +++ b/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts @@ -61,7 +61,7 @@ export class Scala3Renderer extends ConvenienceRenderer { _: EnumType, _enumName: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + return { names: ["_"], includeGlobalForbidden: true }; } protected forbiddenForUnionMembers( @@ -238,6 +238,7 @@ export class Scala3Renderer extends ConvenienceRenderer { let count = c.getProperties().size; let first = true; this.forEachClassProperty(c, "none", (_, jsonName, p) => { + //console.log(jsonName); // Why is this in different order!!!!!! const nullable = p.type.kind === "union" && nullableFromUnion(p.type as UnionType) !== null; @@ -296,6 +297,7 @@ export class Scala3Renderer extends ConvenienceRenderer { } protected emitEnumDefinition(e: EnumType, enumName: Name): void { + //console.log("vanilla"); this.emitDescription(this.descriptionForType(e)); this.emitBlock( @@ -307,6 +309,7 @@ export class Scala3Renderer extends ConvenienceRenderer { } this.forEachEnumCase(e, "none", (name, jsonName) => { + //console.log(jsonName); if (!(jsonName === "")) { const backticks = shouldAddBacktick(jsonName) || diff --git a/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts b/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts index 0eb5ce354c..3728643d39 100644 --- a/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts +++ b/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts @@ -1,14 +1,344 @@ +import type { Name } from "../../Naming"; +import type { Sourcelike } from "../../Source"; +import type { ClassType, EnumType, Type, UnionType } from "../../Type"; +import { + matchType, + nullableFromUnion, + removeNullFromUnion, +} from "../../Type/TypeUtils"; + import { Scala3Renderer } from "./Scala3Renderer"; +import { shouldAddBacktick, wrapOption } from "./utils"; export class UpickleRenderer extends Scala3Renderer { + private seenUnionTypes: string[] = []; + + protected upickleEncoderForType( + t: Type, + __ = false, + noOptional = false, + paramName = "", + ): Sourcelike { + return matchType( + t, + (_anyType) => ["OptionPickler.writeJs(", paramName, ")"], + (_nullType) => ["OptionPickler.writeJs(", paramName, ")"], + (_boolType) => ["OptionPickler.writeJs(", paramName, ")"], + (_integerType) => ["OptionPickler.writeJs(", paramName, ")"], + (_doubleType) => ["OptionPickler.writeJs(", paramName, ")"], + (_stringType) => ["OptionPickler.writeJs(", paramName, ")"], + (arrayType) => [ + "OptionPickler.writeJs[", + this.scalaType(arrayType.items), + "].apply(", + paramName, + ")", + ], + (classType) => [ + "OptionPickler.writeJs[", + this.scalaType(classType), + "].apply(", + paramName, + ")", + ], + (mapType) => [ + "OptionPickler.writeJs[String,", + this.scalaType(mapType.values), + "].apply(", + paramName, + ")", + ], + (_) => ["OptionPickler.writeJs(", paramName, ")"], + (unionType) => { + const nullable = nullableFromUnion(unionType); + if (nullable !== null) { + if (noOptional) { + return [ + "OptionPickler.writeJs[", + this.nameForNamedType(nullable), + "]", + ]; + } + + return [ + "OptionPickler.writeJs[Option[", + this.nameForNamedType(nullable), + "]]", + ]; + } + + return [ + "OptionPickler.writeJs[", + this.nameForNamedType(unionType), + "]", + ]; + }, + ); + } + protected emitClassDefinitionMethods(): void { - this.emitLine(") derives ReadWriter "); + this.emitLine(") derives OptionPickler.ReadWriter "); + } + + protected anySourceType(optional: boolean): Sourcelike { + return [wrapOption("ujson.Value", optional)]; } protected emitHeader(): void { super.emitHeader(); + const optionPickler = `object OptionPickler extends upickle.AttributeTagged : + import upickle.default.Writer + import upickle.default.Reader + override implicit def OptionWriter[T: Writer]: Writer[Option[T]] = + implicitly[Writer[T]].comap[Option[T]] { + case None => null.asInstanceOf[T] + case Some(x) => x + } - this.emitLine("import upickle.default.*"); + override implicit def OptionReader[T: Reader]: Reader[Option[T]] = { + new Reader.Delegate[Any, Option[T]](implicitly[Reader[T]].map(Some(_))){ + override def visitNull(index: Int) = None + } + } +end OptionPickler + +object JsonExt: + val valueReader = OptionPickler.readwriter[ujson.Value] + def badMerge[T](r1: => OptionPickler.Reader[?], rest: OptionPickler.Reader[?]*): OptionPickler.Reader[T] = valueReader.map { json => + var t: T | Null = null + val stack = Vector.newBuilder[Throwable] + (r1 +: rest).foreach { reader => + if t == null then + try + t = OptionPickler.read[T](json, trace = true)(using reader.asInstanceOf[OptionPickler.Reader[T]]) + catch + case exc => stack += exc + } + if t != null then t.nn else throw new Exception(json.toString(), stack.result().headOption.getOrElse(null)) + } + + extension [T](r: OptionPickler.Reader[T]) def widen[K >: T] = r.map(_.asInstanceOf[K]) +end JsonExt +`; + // const singletonPickler = `given singletonStringPickler[A <: Singleton](using A <:< String): OptionPickler.ReadWriter[A] = OptionPickler.readwriter[ujson.Value].bimap[A]( + // _.toString(), + // str => { + // str.toString().asInstanceOf[A]() + // } + // )`; + this.emitMultiline(optionPickler); + this.ensureBlankLine(); + // this.emitMultiline(singletonPickler); + } + + protected override emitEmptyClassDefinition( + c: ClassType, + className: Name, + ): void { + super.emitEmptyClassDefinition(c, className); + this.emitItem(" derives OptionPickler.ReadWriter"); this.ensureBlankLine(); } + + protected override emitUnionDefinition( + u: UnionType, + unionName: Name, + ): void { + // function sortBy(t: Type): string { + // const kind = t.kind; + // if (kind === "class") return kind; + // return "_" + kind; + // } + + this.emitDescription(this.descriptionForType(u)); + + const [maybeNull, nonNulls] = removeNullFromUnion(u, false); + const theTypes: Sourcelike[] = []; + this.forEachUnionMember(u, nonNulls, "none", null, (_, t) => { + theTypes.push(this.scalaType(t)); + }); + if (maybeNull !== null) { + theTypes.push(this.nameForUnionMember(u, maybeNull)); + } + + this.emitItem(["type ", unionName, " = "]); + theTypes.forEach((t, i) => { + this.emitItem(i === 0 ? t : [" | ", t]); + }); + const thisUnionType = theTypes + .map((x) => this.sourcelikeToString(x)) + .join(" | "); + + this.ensureBlankLine(); + if (!this.seenUnionTypes.some((y) => y === thisUnionType)) { + this.seenUnionTypes.push(thisUnionType); + const sourceLikeTypes: Array<[Sourcelike, Type]> = []; + this.forEachUnionMember(u, nonNulls, "none", null, (_, t) => { + sourceLikeTypes.push([this.scalaType(t), t]); + }); + if (maybeNull !== null) { + sourceLikeTypes.push([ + this.nameForUnionMember(u, maybeNull), + maybeNull, + ]); + } + + this.ensureBlankLine(); + this.emitLine([ + "given unionWriter", + unionName, + ": OptionPickler.Reader[", + unionName, + "] = JsonExt.badMerge[", + unionName, + "](", + ]); + this.indent(() => { + sourceLikeTypes.forEach((t) => { + this.emitLine([ + "summon[OptionPickler.Reader[", + t[0], + "]],", + ]); + }); + this.emitLine(")"); + }); + this.ensureBlankLine(); + this.emitLine([ + "given unionReader", + unionName, + ": OptionPickler.Writer[", + unionName, + "] = OptionPickler.writer[ujson.Value].comap[", + unionName, + "]{ _v =>", + ]); + this.indent(() => { + this.emitLine("(_v: @unchecked) match "); + this.indent(() => { + sourceLikeTypes.forEach((t) => { + this.emitLine([ + "case v: ", + t[0], + " => OptionPickler.write[", + t[0], + "](v)", + ]); + }); + }); + }); + this.emitLine("}"); + } + } + + protected emitEnumDefinition(e: EnumType, enumName: Name): void { + this.emitDescription(this.descriptionForType(e)); + + let hasBlank = false; + this.forEachEnumCase(e, "none", (_, jsonName) => { + if (jsonName.trim() === "") { + hasBlank = true; + } + }); + this.ensureBlankLine(); + if (hasBlank) { + //console.log("enumName: " + enumName + " has blank"); + this.emitItem(["type ", enumName, ' = "" | ', enumName, "NonBlank"]); + this.ensureBlankLine(); + this.emitLine([ + "given singleton", + enumName, + 'Pickler[A <: "" | ', + enumName, + "NonBlank]: OptionPickler.ReadWriter[A] = ", + ]); + + this.indent(() => { + this.emitLine([ + "OptionPickler.readwriter[ujson.Value].bimap[A](", + ]); + this.indent(() => { + this.emitLine(["_.toString(),"]); + this.emitLine(["str => {"]); + this.indent(() => { + this.emitLine(["val str2 = str.str"]); + this.emitLine(["str2 match {"]); + this.indent(() => { + this.emitLine([ + 'case _ if str2.length == 0 => "".asInstanceOf[A] ', + ]); + this.emitLine([ + "case parseable =>", + enumName, + "NonBlank.valueOf(parseable).asInstanceOf[A] ", + ]); + }); + this.emitLine(["}"]); + }); + this.emitLine(["}"]); + }); + this.emitLine([")"]); + }); + this.ensureBlankLine(); + //let count = e.cases.size; + this.ensureBlankLine(); + this.emitLine([ + "enum ", + enumName, + "NonBlank derives OptionPickler.ReadWriter: ", + ]); + this.indent(() => { + this.forEachEnumCase(e, "none", (_, jsonName) => { + if (!(jsonName.trim() === "")) { + let strBuild = ""; + const backticks = + shouldAddBacktick(jsonName) || + jsonName.includes(" ") || + !Number.isNaN(Number.parseInt(jsonName.charAt(0))); + this.emitItem(["case "]); + if (backticks) { + strBuild = strBuild + "`"; + } + + strBuild = strBuild + jsonName; + if (backticks) { + strBuild = strBuild + "`"; + } + + // if (--count > 0) strBuild + ","; + this.emitLine([strBuild]); + } + }); + }); + } else { + //console.log("enumName: " + enumName + " has non blank"); + this.emitLine([ + "enum ", + enumName, + " derives OptionPickler.ReadWriter: ", + ]); + + this.indent(() => { + this.forEachEnumCase(e, "none", (_, jsonName) => { + let strBuild = ""; + const backticks = + shouldAddBacktick(jsonName) || + jsonName.includes(" ") || + !Number.isNaN(Number.parseInt(jsonName.charAt(0))); + this.emitItem(["case "]); + if (backticks) { + strBuild = strBuild + "`"; + } + + strBuild = strBuild + jsonName; + if (backticks) { + strBuild = strBuild + "`"; + } + + // if (--count > 0) strBuild + ","; + this.emitLine([strBuild]); + }); + }); + } + } } diff --git a/packages/quicktype-core/src/language/Scala3/constants.ts b/packages/quicktype-core/src/language/Scala3/constants.ts index 918172d7fe..5cdbd2b545 100644 --- a/packages/quicktype-core/src/language/Scala3/constants.ts +++ b/packages/quicktype-core/src/language/Scala3/constants.ts @@ -1,5 +1,6 @@ // Use backticks for param names with symbols export const invalidSymbols = [ + "?", ":", "-", "+", diff --git a/test/fixtures.ts b/test/fixtures.ts index 0472762b33..75894f22b9 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1564,8 +1564,8 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.FlowLanguage), new JSONFixture(languages.JavaScriptLanguage), new JSONFixture(languages.KotlinLanguage), - new JSONFixture(languages.Scala3Language), new JSONFixture(languages.KotlinJacksonLanguage, "kotlin-jackson"), + new JSONFixture(languages.Scala3Language), new JSONFixture(languages.DartLanguage), new JSONFixture(languages.PikeLanguage), new JSONFixture(languages.HaskellLanguage), diff --git a/test/fixtures/scala3-upickle/.gitignore b/test/fixtures/scala3-upickle/.gitignore new file mode 100644 index 0000000000..03df7b5603 --- /dev/null +++ b/test/fixtures/scala3-upickle/.gitignore @@ -0,0 +1,4 @@ +main.jar +.bsp +.scala-build +rename/ \ No newline at end of file diff --git a/test/fixtures/scala3-upickle/run.sh b/test/fixtures/scala3-upickle/run.sh new file mode 100755 index 0000000000..7707d38414 --- /dev/null +++ b/test/fixtures/scala3-upickle/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +scala-cli upickle.scala TopLevel.scala diff --git a/test/fixtures/scala3-upickle/upickle.scala b/test/fixtures/scala3-upickle/upickle.scala new file mode 100644 index 0000000000..5071588087 --- /dev/null +++ b/test/fixtures/scala3-upickle/upickle.scala @@ -0,0 +1,17 @@ +//> using scala "3.2.2" +//> using lib "com.lihaoyi::upickle:3.1.0" +//> using lib "com.lihaoyi::os-lib:0.9.1" +//> using options "-Xmax-inlines", "500000" + +package quicktype +import upickle.default.* + + +@main def main = { + val json = scala.io.Source.fromFile("sample.json").getLines.mkString + val parsed = OptionPickler.read[TopLevel](json) + val jsonString = OptionPickler.writeJs(parsed) + val arr : Array[Byte] = jsonString.toString.getBytes("UTF-8") + // os.write(os.pwd / "my.json", OptionPickler.write(parsed, 2)) + System.out.write(arr, 0, arr.length) +} diff --git a/test/fixtures/scala3/circe.scala b/test/fixtures/scala3/circe.scala index b415f95451..318be045bc 100644 --- a/test/fixtures/scala3/circe.scala +++ b/test/fixtures/scala3/circe.scala @@ -1,12 +1,16 @@ -//> using scala "3.2.1" -//> using lib "io.circe::circe-core:0.15.0-M1" -//> using lib "io.circe::circe-parser:0.15.0-M1" +//> using scala "3.2.2" +//> using lib "io.circe::circe-core:0.14.5" +//> using lib "io.circe::circe-parser:0.14.5" //> using options "-Xmax-inlines", "3000" package quicktype import io.circe._ import io.circe.parser._ import io.circe.syntax._ +import scala.util.Try +import scala.util.Right +import scala.util.Left + /* case class TopLevel ( diff --git a/test/languages.ts b/test/languages.ts index 638c3a91d2..a3bd56ed44 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1055,6 +1055,7 @@ I havea no idea how to encode these tests correctly. ...skipsEnumValueValidation, "class-with-additional.schema", "keyword-unions.schema", + "keyword-enum.schema", ], skipMiscJSON: false, rendererOptions: { framework: "circe" },