diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 0b1c562522..ca58770bf3 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -53,6 +53,7 @@ jobs: - ruby - php,schema-php - scala3,schema-scala3 + - scala3-upickle,schema-scala3-upickle - elixir,schema-elixir,graphql-elixir - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema diff --git a/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts b/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts index 5f513c0c5f..fc4a23dd39 100644 --- a/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts +++ b/packages/quicktype-core/src/language/Scala3/CirceRenderer.ts @@ -14,8 +14,10 @@ import type { UnionType, } from "../../Type/index.js"; +import { stringEscape } from "../../support/Strings.js"; + import { Scala3Renderer } from "./Scala3Renderer.js"; -import { wrapOption } from "./utils.js"; +import { unionMemberSortOrder, wrapOption } from "./utils.js"; export class CirceRenderer extends Scala3Renderer { private readonly seenUnionTypes: string[] = []; @@ -55,7 +57,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) { @@ -103,44 +111,70 @@ export class CirceRenderer extends Scala3Renderer { protected emitEnumDefinition(e: EnumType, enumName: Name): void { this.emitDescription(this.descriptionForType(e)); + this.ensureBlankLine(); + // Enum cases are styled Scala identifiers; the codecs below map + // them back to the original JSON strings, which can be anything + // (keywords, `"_"`, `""`, …). + this.emitLine(["enum ", enumName, " : "]); + this.indent(() => { + this.forEachEnumCase(e, "none", (name) => { + this.emitLine("case ", name); + }); + }); this.ensureBlankLine(); - this.emitItem(["type ", enumName, " = "]); - let count = e.cases.size; - 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 - // } + + this.emitLine([ + "given Decoder[", + enumName, + "] = Decoder.decodeString.emap {", + ]); + this.indent(() => { + // `scala.` in case a generated type is named Right/Left. + this.forEachEnumCase(e, "none", (name, jsonName) => { + this.emitLine([ + `case "${stringEscape(jsonName)}" => scala.Right(`, + enumName, + ".", + name, + ")", + ]); + }); + this.emitLine([ + 'case other => scala.Left("invalid ', + enumName, + ': " + other)', + ]); }); + this.emitLine("}"); + this.emitLine([ + "given Encoder[", + enumName, + "] = Encoder.encodeString.contramap {", + ]); + this.indent(() => { + this.forEachEnumCase(e, "none", (name, jsonName) => { + this.emitLine([ + "case ", + enumName, + ".", + name, + ` => "${stringEscape(jsonName)}"`, + ]); + }); + }); + this.emitLine("}"); this.ensureBlankLine(); } protected emitHeader(): void { super.emitHeader(); - this.emitLine("import scala.util.Try"); this.emitLine("import io.circe.syntax._"); this.emitLine("import io.circe._"); this.emitLine("import cats.syntax.functor._"); 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.ensureBlankLine(); this.emitLine( "// If a union has a null in, then we'll need this too... ", ); @@ -153,9 +187,9 @@ export class CirceRenderer extends Scala3Renderer { this.emitLine([ "given (using ev : ", elementType, - "): Encoder[Map[String,", + "): Encoder[Seq[", elementType, - "]] = Encoder.encodeMap[String, ", + "]] = Encoder.encodeSeq[", elementType, "]", ]); @@ -177,15 +211,9 @@ export class CirceRenderer extends Scala3Renderer { } protected 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, sortBy); + const [maybeNull, nonNulls] = removeNullFromUnion(u, false); const theTypes: Sourcelike[] = []; this.forEachUnionMember(u, nonNulls, "none", null, (_, t) => { theTypes.push(this.scalaType(t)); @@ -205,10 +233,20 @@ export class CirceRenderer extends Scala3Renderer { this.ensureBlankLine(); if (!this.seenUnionTypes.some((y) => y === thisUnionType)) { this.seenUnionTypes.push(thisUnionType); + // The decoders are tried in order, most discriminating + // first (see `unionMemberSortOrder`): circe's numeric + // decoders accept strings like "5", so the string decoder + // has to get a shot before them. const sourceLikeTypes: Array<[Sourcelike, Type]> = []; - this.forEachUnionMember(u, nonNulls, "none", null, (_, t) => { - sourceLikeTypes.push([this.scalaType(t), t]); - }); + this.forEachUnionMember( + u, + nonNulls, + "none", + unionMemberSortOrder, + (_, t) => { + sourceLikeTypes.push([this.scalaType(t), t]); + }, + ); if (maybeNull !== null) { sourceLikeTypes.push([ this.nameForUnionMember(u, maybeNull), diff --git a/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts b/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts index 34234f728e..09b6e59fcf 100644 --- a/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts +++ b/packages/quicktype-core/src/language/Scala3/Scala3Renderer.ts @@ -30,9 +30,10 @@ import { import { keywords } from "./constants.js"; import type { scala3Options } from "./language.js"; import { + backtickedName, + enumCaseNameStyle, lowerNamingFunction, scalaNameStyle, - shouldAddBacktick, upperNamingFunction, wrapOption, } from "./utils.js"; @@ -61,7 +62,7 @@ export class Scala3Renderer extends ConvenienceRenderer { _: EnumType, _enumName: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + return { names: ["_"], includeGlobalForbidden: true }; } protected forbiddenForUnionMembers( @@ -91,7 +92,7 @@ export class Scala3Renderer extends ConvenienceRenderer { } protected makeEnumCaseNamer(): Namer { - return funPrefixNamer("upper", (s) => s.replace(" ", "")); // TODO - add backticks where appropriate + return funPrefixNamer("upper", enumCaseNameStyle); } protected emitDescriptionBlock(lines: Sourcelike[]): void { @@ -262,14 +263,9 @@ export class Scala3Renderer extends ConvenienceRenderer { emit(); } - const nameNeedsBackticks = - jsonName.endsWith("_") || shouldAddBacktick(jsonName); - const nameWithBackticks = nameNeedsBackticks - ? `\`${jsonName}\`` - : jsonName; this.emitLine( "val ", - nameWithBackticks, + backtickedName(jsonName), " : ", scalaType(p), p.isOptional @@ -301,32 +297,8 @@ export class Scala3Renderer extends ConvenienceRenderer { this.emitBlock( ["enum ", enumName, " : "], () => { - let count = e.cases.size; - if (count > 0) { - this.emitItem("\t case "); - } - - this.forEachEnumCase(e, "none", (name, jsonName) => { - if (!(jsonName === "")) { - const backticks = - shouldAddBacktick(jsonName) || - jsonName.includes(" ") || - !Number.isNaN( - Number.parseInt(jsonName.charAt(0), 10), - ); - if (backticks) { - this.emitItem("`"); - } - - this.emitItemOnce([name]); - if (backticks) { - this.emitItem("`"); - } - - if (--count > 0) this.emitItem([","]); - } else { - --count; - } + this.forEachEnumCase(e, "none", (name) => { + this.emitLine("case ", name); }); }, "none", @@ -361,12 +333,14 @@ export class Scala3Renderer extends ConvenienceRenderer { protected emitSourceStructure(): void { this.emitHeader(); - // Top-level arrays, maps + // Top-level arrays, maps, and primitives this.forEachTopLevel("leading", (t, name) => { if (t instanceof ArrayType) { this.emitTopLevelArray(t, name); } else if (t instanceof MapType) { this.emitTopLevelMap(t, name); + } else if (t.isPrimitive()) { + this.emitLine(["type ", name, " = ", this.scalaType(t)]); } }); diff --git a/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts b/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts index d985ed1694..bc274f3c4d 100644 --- a/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts +++ b/packages/quicktype-core/src/language/Scala3/UpickleRenderer.ts @@ -1,14 +1,263 @@ +import type { Name } from "../../Naming.js"; +import type { Sourcelike } from "../../Source.js"; +import { removeNullFromUnion } from "../../Type/TypeUtils.js"; +import type { ClassType, EnumType, Type, UnionType } from "../../Type/index.js"; +import { stringEscape } from "../../support/Strings.js"; + import { Scala3Renderer } from "./Scala3Renderer.js"; +import { unionMemberSortOrder, wrapOption } from "./utils.js"; export class UpickleRenderer extends Scala3Renderer { + private readonly seenUnionTypes: string[] = []; + 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(); + this.emitMultiline(`// Custom pickler so that missing keys and JSON nulls both read as None, +// and None is left out when writing (upickle's default for Option is a +// JSON array). +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 + } + + 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 + +// If a union has a null in, then we'll need this too... +type NullValue = None.type +given OptionPickler.ReadWriter[NullValue] = OptionPickler.readwriter[ujson.Value].bimap[NullValue]( + _ => ujson.Null, + json => if json.isNull then None else throw new upickle.core.Abort("not null") +) + +object JsonExt: + val valueReader = OptionPickler.readwriter[ujson.Value] + + // upickle's built-in primitive readers are lenient -- the numeric and + // boolean readers accept strings, and the string reader accepts + // numbers and booleans -- so untagged unions need strict readers to + // pick the right member. + val strictString: OptionPickler.Reader[String] = valueReader.map { + case ujson.Str(s) => s + case json => throw new upickle.core.Abort("expected string, got " + json) + } + val strictLong: OptionPickler.Reader[Long] = valueReader.map { + case ujson.Num(n) if n.isWhole => n.toLong + case json => throw new upickle.core.Abort("expected integer, got " + json) + } + val strictDouble: OptionPickler.Reader[Double] = valueReader.map { + case ujson.Num(n) => n + case json => throw new upickle.core.Abort("expected number, got " + json) + } + val strictBoolean: OptionPickler.Reader[Boolean] = valueReader.map { + case ujson.Bool(b) => b + case json => throw new upickle.core.Abort("expected boolean, got " + json) + } + + 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)) + } +end JsonExt +`); + this.ensureBlankLine(); + } + + 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 { + 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", + unionMemberSortOrder, + (_, t) => { + sourceLikeTypes.push([this.scalaType(t), t]); + }, + ); + if (maybeNull !== null) { + sourceLikeTypes.push([ + this.nameForUnionMember(u, maybeNull), + maybeNull, + ]); + } + + this.ensureBlankLine(); + this.emitLine([ + "given unionReader", + unionName, + ": OptionPickler.Reader[", + unionName, + "] = JsonExt.badMerge[", + unionName, + "](", + ]); + this.indent(() => { + sourceLikeTypes.forEach(([srcType, t]) => { + // Use the strict readers for primitive members -- + // upickle's built-in ones accept too much (see the + // comment in the emitted JsonExt). + const strictReaders: Partial> = { + Boolean: "JsonExt.strictBoolean", + Double: "JsonExt.strictDouble", + Long: "JsonExt.strictLong", + String: "JsonExt.strictString", + }; + const strict = t.isPrimitive() + ? strictReaders[this.sourcelikeToString(srcType)] + : undefined; + if (strict === undefined) { + this.emitLine([ + "summon[OptionPickler.Reader[", + srcType, + "]],", + ]); + } else { + this.emitLine([strict, ","]); + } + }); + this.emitLine(")"); + }); + this.ensureBlankLine(); + this.emitLine([ + "given unionWriter", + 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.writeJs[", + t[0], + "](v)", + ]); + }); + }); + }); + this.emitLine("}"); + } + } + + protected emitEnumDefinition(e: EnumType, enumName: Name): void { + this.emitDescription(this.descriptionForType(e)); + this.ensureBlankLine(); + + // Enum cases are styled Scala identifiers; the ReadWriter below + // maps them back to the original JSON strings, which can be + // anything (keywords, `"_"`, `""`, …). + this.emitLine(["enum ", enumName, " : "]); + this.indent(() => { + this.forEachEnumCase(e, "none", (name) => { + this.emitLine("case ", name); + }); + }); + this.ensureBlankLine(); - this.emitLine("import upickle.default.*"); + this.emitLine([ + "given OptionPickler.ReadWriter[", + enumName, + "] = OptionPickler.readwriter[String].bimap[", + enumName, + "](", + ]); + this.indent(() => { + this.emitLine("{"); + this.indent(() => { + this.forEachEnumCase(e, "none", (name, jsonName) => { + this.emitLine([ + "case ", + enumName, + ".", + name, + ` => "${stringEscape(jsonName)}"`, + ]); + }); + }); + this.emitLine("},"); + this.emitLine("{"); + this.indent(() => { + this.forEachEnumCase(e, "none", (name, jsonName) => { + this.emitLine([ + `case "${stringEscape(jsonName)}" => `, + enumName, + ".", + name, + ]); + }); + this.emitLine([ + 'case other => throw new upickle.core.Abort("invalid ', + enumName, + ': " + other)', + ]); + }); + this.emitLine("}"); + }); + this.emitLine(")"); this.ensureBlankLine(); } } 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/packages/quicktype-core/src/language/Scala3/utils.ts b/packages/quicktype-core/src/language/Scala3/utils.ts index 3c7258dc44..638855d9b7 100644 --- a/packages/quicktype-core/src/language/Scala3/utils.ts +++ b/packages/quicktype-core/src/language/Scala3/utils.ts @@ -1,4 +1,5 @@ -import { funPrefixNamer } from "../../Naming.js"; +import { type Name, funPrefixNamer } from "../../Naming.js"; +import type { Type } from "../../Type/index.js"; import { allLowerWordStyle, allUpperWordStyle, @@ -26,6 +27,15 @@ export const shouldAddBacktick = (paramName: string): boolean => { ); }; +/** + * Wrap a name in backticks if it isn't usable as a plain Scala identifier. + */ +export const backtickedName = (name: string): string => { + return name.endsWith("_") || name.includes(" ") || shouldAddBacktick(name) + ? `\`${name}\`` + : name; +}; + export const wrapOption = (s: string, optional: boolean): string => { if (optional) { return `Option[${s}]`; @@ -34,6 +44,29 @@ export const wrapOption = (s: string, optional: boolean): string => { } }; +/** + * Sort order for union members when emitting the decoders of an untagged + * union, which try each member in turn: both circe's and upickle's + * primitive number/boolean decoders are lenient (they accept strings like + * "5"), and a case class whose fields are all defaulted can spuriously + * read non-object JSON with upickle, so try the most discriminating + * decoders first -- enums (which accept only their known strings), then + * strings, then the other primitives, classes last. + */ +export const unionMemberSortOrder = (_: Name, t: Type): string => { + const priority: Partial> = { + enum: "0", + string: "2", + bool: "3", + integer: "4", + double: "5", + array: "6", + map: "7", + class: "9", + }; + return `${priority[t.kind] ?? "8"}${t.kind}`; +}; + function isPartCharacter(codePoint: number): boolean { return isLetterOrUnderscore(codePoint) || isNumeric(codePoint); } @@ -58,16 +91,19 @@ export function scalaNameStyle(isUpper: boolean, original: string): string { ); } -/* function unicodeEscape(codePoint: number): string { - return "\\u" + intToHex(codePoint, 4); -} */ - -// const _stringEscape = utf32ConcatMap(escapeNonPrintableMapper(isPrintable, unicodeEscape)); - -/* function stringEscape(s: string): string { - // "$this" is a template string in Kotlin so we have to escape $ - return _stringEscape(s).replace(/\$/g, "\\$"); -} */ +/** + * Style an enum case as a legal Scala identifier. The JSON string an enum + * case comes from can be anything (a keyword, `"_"`, `""`, …), so the + * renderers emit styled case names and map them back to the original JSON + * strings in their codecs. + */ +export function enumCaseNameStyle(original: string): string { + const styled = scalaNameStyle(true, original); + // `scalaNameStyle` can produce the empty string, for example for `""` + // or `"_"` (which is not a legal identifier even in backticks). The + // namer disambiguates if an enum has several such cases. + return styled === "" ? "Empty" : styled; +} export const upperNamingFunction = funPrefixNamer("upper", (s) => scalaNameStyle(true, s), diff --git a/test/fixtures.ts b/test/fixtures.ts index a49aef802f..6d22a9ac7c 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1566,6 +1566,7 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.JavaScriptLanguage), new JSONFixture(languages.KotlinLanguage), new JSONFixture(languages.Scala3Language), + new JSONFixture(languages.Scala3UpickleLanguage, "scala3-upickle"), new JSONFixture(languages.KotlinJacksonLanguage, "kotlin-jackson"), new JSONFixture(languages.KotlinXLanguage, "kotlinx"), new JSONFixture(languages.DartLanguage), @@ -1609,6 +1610,10 @@ export const allFixtures: Fixture[] = [ ), new JSONSchemaFixture(languages.KotlinXLanguage, "schema-kotlinx"), new JSONSchemaFixture(languages.Scala3Language), + new JSONSchemaFixture( + languages.Scala3UpickleLanguage, + "schema-scala3-upickle", + ), new JSONSchemaFixture(languages.DartLanguage), new JSONSchemaFixture(languages.PikeLanguage), new JSONSchemaFixture(languages.HaskellLanguage), diff --git a/test/fixtures/scala3-upickle/.gitignore b/test/fixtures/scala3-upickle/.gitignore new file mode 100644 index 0000000000..f252b419dc --- /dev/null +++ b/test/fixtures/scala3-upickle/.gitignore @@ -0,0 +1,3 @@ +main.jar +.bsp +.scala-build 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..d3e72865a5 --- /dev/null +++ b/test/fixtures/scala3-upickle/upickle.scala @@ -0,0 +1,13 @@ +//> using scala "3.2.2" +//> using dep "com.lihaoyi::upickle:3.1.0" +//> using options "-Xmax-inlines", "500000" + +package quicktype + +@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") + System.out.write(arr, 0, arr.length) +} diff --git a/test/fixtures/scala3/circe.scala b/test/fixtures/scala3/circe.scala index b415f95451..a8e4b49867 100644 --- a/test/fixtures/scala3/circe.scala +++ b/test/fixtures/scala3/circe.scala @@ -1,30 +1,23 @@ -//> 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 dep "io.circe::circe-core:0.14.5" +//> using dep "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._ -/* - -case class TopLevel ( - val data : Long, - val next : Option[TopLevel] = None -) derives Encoder.AsObject, Decoder */ - -@main def main = - val json = scala.io.Source.fromFile("sample.json").getLines.mkString - - parse(json).map(x => - val arg = x.as[TopLevel] - arg match { - case Right(y) => - val jsonString = y.asJson - val arr : Array[Byte] = jsonString.toString.getBytes("UTF-8") - System.out.write(arr, 0, arr.length) - case Left(y) => println(y); println("-----");println(y.getMessage) - } - ) +@main def main = { + val json = scala.io.Source.fromFile("sample.json").getLines.mkString + // `scala.` in case the generated code has types named Right/Left. + parse(json).flatMap(_.as[TopLevel]) match { + case scala.Right(y) => + val arr: Array[Byte] = y.asJson.toString.getBytes("UTF-8") + System.out.write(arr, 0, arr.length) + case scala.Left(err) => + System.err.println(err) + sys.exit(1) + } +} diff --git a/test/languages.ts b/test/languages.ts index b64b21cfca..ec000aaac4 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1044,57 +1044,81 @@ export const Scala3Language: Language = { return `cp "${sample}" sample.json && ./run.sh`; }, diffViaSchema: true, - skipDiffViaSchema: ["bug427.json", "keywords.json"], + skipDiffViaSchema: [ + "bug427.json", + // These round-trip fine; the code generated via JSON Schema + // orders one property differently (a pre-existing + // alphabetization quirk around renamed keyword properties). + "github-events.json", + "0a91a.json", + "34702.json", + "76ae1.json", + "af2d1.json", + ], allowMissingNull: true, features: ["enum", "union", "no-defaults"], output: "TopLevel.scala", topLevel: "TopLevel", skipJSON: [ - // These tests have "_" as a param name. Scala can't do this? + // The renderer emits raw JSON property names as (backticked) + // Scala identifiers, so empty names, a bare "_", and names + // containing backticks or line separators cannot compile, and + // properties named "None"/"Option" generate case classes that + // shadow the Scala prelude. "blns-object.json", "identifiers.json", "simple-identifiers.json", "keywords.json", + "nst-test-suite.json", - // these actually work as far as I can tell, but seem to fail because properties are sorted differently - // I don't think they fail... but I can't figure out sorting so hey ho let's skip them + // Scala3 has the same prelude-shadowing bug that this input + // guards against in Rust (issue #2521): a field named "options" + // generates `case class Option`, which shadows scala.Option. + "bug2521.json", + ], + skipSchema: [ + // Same raw-identifier limitation as in skipJSON: a property + // named "_" and classes shadowing None/Option don't compile. + "keyword-unions.schema", + ], + skipMiscJSON: false, + rendererOptions: { framework: "circe" }, + quickTestRendererOptions: [], + sourceFiles: ["src/language/Scala3/index.ts"], +}; + +export const Scala3UpickleLanguage: Language = { + name: "scala3", + base: "test/fixtures/scala3-upickle", + runCommand(sample: string) { + return `cp "${sample}" sample.json && ./run.sh`; + }, + diffViaSchema: true, + skipDiffViaSchema: [ + "bug427.json", + // These round-trip fine; the code generated via JSON Schema + // orders one property differently (a pre-existing + // alphabetization quirk around renamed keyword properties). "github-events.json", - "0a358.json", "0a91a.json", "34702.json", "76ae1.json", "af2d1.json", - "bug427.json", - "3d04a0.json", - - // Top level primitives... trivial, - // but annoying as it breaks compilation of the "Top Level" construct... which doesn't exist. - // It's too much hassle to fix - // and has no practical application in this context. Skip. - "no-classes.json", - - // spaces in variables names doesn't seem to work - "name-style.json", - - /* -I havea no idea how to encode these tests correctly. -*/ - "kitchen-sink.json", - "26c9c.json", - "421d4.json", - "a0496.json", - "fcca3.json", - "ae9ca.json", - "617e8.json", - "5f7fe.json", - "f74d5.json", - "a3d8c.json", - "combinations1.json", - "combinations2.json", - "combinations3.json", - "combinations4.json", - "unions.json", - "php-mixed-union.json", + ], + allowMissingNull: true, + features: ["enum", "union", "no-defaults"], + output: "TopLevel.scala", + topLevel: "TopLevel", + skipJSON: [ + // The renderer emits raw JSON property names as (backticked) + // Scala identifiers, so empty names, a bare "_", and names + // containing backticks or line separators cannot compile, and + // properties named "None"/"Option" generate case classes that + // shadow the Scala prelude. + "blns-object.json", + "identifiers.json", + "simple-identifiers.json", + "keywords.json", "nst-test-suite.json", // Scala3 has the same prelude-shadowing bug that this input @@ -1103,25 +1127,12 @@ I havea no idea how to encode these tests correctly. "bug2521.json", ], skipSchema: [ - // 12 skips - "required.schema", - "multi-type-enum.schema", // I think it doesn't correctly realise this is an array of enums. - "integer-string.schema", - "intersection.schema", - ...skipsUntypedUnions, - // The test driver prints the circe DecodingFailure and exits 0, so - // expected-failure samples cannot be detected. - "nested-intersection-union.schema", - "prefix-items.schema", - "date-time-or-string.schema", - "implicit-one-of.schema", - "go-schema-pattern-properties.schema", - ...skipsEnumValueValidation, - "class-with-additional.schema", + // Same raw-identifier limitation as in skipJSON: a property + // named "_" and classes shadowing None/Option don't compile. "keyword-unions.schema", ], skipMiscJSON: false, - rendererOptions: { framework: "circe" }, + rendererOptions: { framework: "upickle" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Scala3/index.ts"], };