diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 0e53ae7b11..0b1c562522 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -39,6 +39,7 @@ jobs: - javascript,schema-javascript - golang,schema-golang - cjson,schema-cjson + - cjson-default,cjson-multi-header,cjson-multi-split - cplusplus,schema-cplusplus - flow,schema-flow - java,schema-java diff --git a/.gitignore b/.gitignore index 7178f4122b..747cce08bd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,7 @@ test/golang/schema-from-schema.json test/elm/elm-stuff/ test/elm/elm.js test/elm/QuickType.elm -test/fixtures/cjson/cJSON.* -test/fixtures/cjson/hashtable.* -test/fixtures/cjson/list.* +test/fixtures/cjson/deps/ test/fixtures/rust/target test/fixtures/java/target test/fixtures/java-lombok/target diff --git a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts index c1f3544657..c750529c98 100644 --- a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts +++ b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts @@ -57,7 +57,13 @@ import { } from "./utils.js"; export class CJSONRenderer extends ConvenienceRenderer { - private currentFilename: string | undefined; /* Current filename */ + private currentHeaderFilename: + | string + | undefined; /* Current header filename */ + + private currentSourceFilename: + | string + | undefined; /* Current source filename */ private readonly memberNameStyle: NameStyle; /* Member name style */ @@ -231,8 +237,8 @@ export class CJSONRenderer extends ConvenienceRenderer { } /** - * Function called to create header file(s) - * @param proposedFilename: source filename provided from stdin + * Function called to create header and source file(s) + * @param proposedFilename: source filename provided from stdin (without extensions) */ protected emitSourceStructure(proposedFilename: string): void { /* Depending of source style option, generate a unique header or multiple header files */ @@ -244,12 +250,12 @@ export class CJSONRenderer extends ConvenienceRenderer { } /** - * Function called to create a single header file with types and generators + * Function called to create a single pair of header/source files with types and generators * @param proposedFilename: source filename provided from stdin */ protected emitSingleSourceStructure(proposedFilename: string): void { - /* Create file */ - this.startFile(proposedFilename); + /* Create header file */ + this.startHeaderFile(proposedFilename); /* Create types */ this.forEachDeclaration("leading-and-interposing", (decl) => { @@ -306,6 +312,14 @@ export class CJSONRenderer extends ConvenienceRenderer { (type) => this.namedTypeToNameForTopLevel(type) === undefined, ); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(proposedFilename); + } + /* Create enum functions */ this.forEachEnum( "leading-and-interposing", @@ -333,8 +347,7 @@ export class CJSONRenderer extends ConvenienceRenderer { (type) => this.namedTypeToNameForTopLevel(type) === undefined, ); - /* Close file */ - this.finishFile(); + this.finishCurrentFile(); } /** @@ -371,12 +384,12 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitEnum(enumType: EnumType): void { /* Create file */ const enumName = this.nameForNamedType(enumType); - const filename = this.sourcelikeToString(enumName).concat(".h"); - this.includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(enumName).concat(".h"); + this.includes.push(headerFilename); + this.startHeaderFile(headerFilename); /* Create includes */ - this.emitIncludes(enumType, this.sourcelikeToString(filename)); + this.emitIncludes(enumType, this.sourcelikeToString(headerFilename)); /* Create types */ this.emitEnumTypedef(enumType); @@ -384,11 +397,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitEnumPrototypes(enumType); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(headerFilename); + } + /* Create functions */ this.emitEnumFunctions(enumType); - /* Close file */ - this.finishFile(); + this.finishCurrentFile(); } /** @@ -548,12 +568,12 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitUnion(unionType: UnionType): void { /* Create file */ const unionName = this.nameForNamedType(unionType); - const filename = this.sourcelikeToString(unionName).concat(".h"); - this.includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(unionName).concat(".h"); + this.includes.push(headerFilename); + this.startHeaderFile(headerFilename); /* Create includes */ - this.emitIncludes(unionType, this.sourcelikeToString(filename)); + this.emitIncludes(unionType, this.sourcelikeToString(headerFilename)); /* Create types */ this.emitUnionTypedef(unionType); @@ -561,11 +581,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitUnionPrototypes(unionType); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(headerFilename); + } + /* Create functions */ this.emitUnionFunctions(unionType); - /* Close file */ - this.finishFile(); + this.finishCurrentFile(); } /** @@ -1991,12 +2018,12 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitClass(classType: ClassType): void { /* Create file */ const className = this.nameForNamedType(classType); - const filename = this.sourcelikeToString(className).concat(".h"); - this.includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(className).concat(".h"); + this.includes.push(headerFilename); + this.startHeaderFile(headerFilename); /* Create includes */ - this.emitIncludes(classType, this.sourcelikeToString(filename)); + this.emitIncludes(classType, this.sourcelikeToString(headerFilename)); /* Create types */ this.emitClassTypedef(classType); @@ -2004,11 +2031,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitClassPrototypes(classType); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(headerFilename); + } + /* Create functions */ this.emitClassFunctions(classType); - /* Close file */ - this.finishFile(); + this.finishCurrentFile(); } /** @@ -4598,8 +4632,8 @@ export class CJSONRenderer extends ConvenienceRenderer { includes: string[], ): void { /* Create file */ - const filename = this.sourcelikeToString(className).concat(".h"); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(className).concat(".h"); + this.startHeaderFile(headerFilename); /* Create includes - This create too much includes but this is safer because of specific corner cases */ includes.forEach((name) => { @@ -4613,11 +4647,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitTopLevelPrototypes(type, className); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(headerFilename); + } + /* Create functions */ this.emitTopLevelFunctions(type, className); - /* Close file */ - this.finishFile(); + this.finishCurrentFile(); } /** @@ -5535,24 +5576,25 @@ export class CJSONRenderer extends ConvenienceRenderer { } /** - * Function called to create a file + * Function called to create a header file * @param proposedFilename: source filename provided from stdin */ - protected startFile(proposedFilename: Sourcelike): void { - /* Check if previous file is closed, create a new file */ + protected startHeaderFile(proposedFilename: Sourcelike): void { + /* Check if previous header file is closed, create a new file */ assert( - this.currentFilename === undefined, - "Previous file wasn't finished", + this.currentHeaderFilename === undefined, + "Previous header file wasn't finished", ); if (proposedFilename !== undefined) { - this.currentFilename = this.sourcelikeToString(proposedFilename); + this.currentHeaderFilename = + this.sourcelikeToString(proposedFilename); } - /* Check if file has been created */ - if (this.currentFilename !== undefined) { + /* Check if header file has been created */ + if (this.currentHeaderFilename !== undefined) { /* Write header */ this.emitDescriptionBlock([ - this.currentFilename, + this.currentHeaderFilename, "This file has been autogenerated using quicktype https://github.com/quicktype/quicktype - DO NOT EDIT", "This file depends of https://github.com/DaveGamble/cJSON, https://github.com/joelguittet/c-list and https://github.com/joelguittet/c-hashtable", "To parse json data from json string use the following: struct * data = cJSON_Parse();", @@ -5567,7 +5609,7 @@ export class CJSONRenderer extends ConvenienceRenderer { this.emitLine( "#ifndef __", allUpperWordStyle( - this.currentFilename.replace( + this.currentHeaderFilename.replace( new RegExp(/[^a-zA-Z0-9]+/, "g"), "_", ), @@ -5577,7 +5619,7 @@ export class CJSONRenderer extends ConvenienceRenderer { this.emitLine( "#define __", allUpperWordStyle( - this.currentFilename.replace( + this.currentHeaderFilename.replace( new RegExp(/[^a-zA-Z0-9]+/, "g"), "_", ), @@ -5617,11 +5659,42 @@ export class CJSONRenderer extends ConvenienceRenderer { } /** - * Function called to close current file + * Function called to create a source file + * @param headerFilename: filename of the header file corresponding to this source file + */ + protected startSourceFile(headerFilename: Sourcelike): void { + /* Check if previous source file is closed, create a new file */ + assert( + this.currentSourceFilename === undefined, + "Previous source file wasn't finished", + ); + if (headerFilename !== undefined) { + this.currentSourceFilename = this.getSourceNameFromHeaderName( + this.sourcelikeToString(headerFilename), + ); + } + + /* Check if source file has been created */ + if (this.currentSourceFilename !== undefined) { + /* Write header */ + this.emitDescriptionBlock([ + this.currentSourceFilename, + "This file has been autogenerated using quicktype https://github.com/quicktype/quicktype - DO NOT EDIT", + ]); + this.ensureBlankLine(); + + /* Include corresponding header file */ + this.emitIncludeLine(this.sourcelikeToString(headerFilename)); + this.ensureBlankLine(); + } + } + + /** + * Function called to close current header file */ - protected finishFile(): void { - /* Check if file has been created */ - if (this.currentFilename !== undefined) { + protected finishHeaderFile(): void { + /* Check if header file has been created */ + if (this.currentHeaderFilename !== undefined) { /* Write C++ guard */ this.emitLine("#ifdef __cplusplus"); this.emitLine("}"); @@ -5632,7 +5705,7 @@ export class CJSONRenderer extends ConvenienceRenderer { this.emitLine( "#endif /* __", allUpperWordStyle( - this.currentFilename.replace( + this.currentHeaderFilename.replace( new RegExp(/[^a-zA-Z0-9]+/, "g"), "_", ), @@ -5641,9 +5714,37 @@ export class CJSONRenderer extends ConvenienceRenderer { ); this.ensureBlankLine(); - /* Close file */ - super.finishFile(defined(this.currentFilename)); - this.currentFilename = undefined; + /* Close header file */ + super.finishFile(defined(this.currentHeaderFilename)); + this.currentHeaderFilename = undefined; + } + } + + /** + * Function called to close current source file + */ + protected finishSourceFile(): void { + /* Check if source file has been created */ + if (this.currentSourceFilename !== undefined) { + this.ensureBlankLine(); + + /* Close source file */ + super.finishFile(defined(this.currentSourceFilename)); + this.currentSourceFilename = undefined; + } + } + + /** + * Function called to close the current file, either the header file when + * generating headers only, or the source file otherwise + */ + protected finishCurrentFile(): void { + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); } } @@ -5909,4 +6010,13 @@ export class CJSONRenderer extends ConvenienceRenderer { recur(false, false, 0, type); return result; } + + /** + * Get the name of the source file corresponding to a header file + * @param headerName: header filename + * @return Source filename + */ + protected getSourceNameFromHeaderName(headerName: string): string { + return headerName.replace(/\.h$/, ".c"); + } } diff --git a/packages/quicktype-core/src/language/CJSON/language.ts b/packages/quicktype-core/src/language/CJSON/language.ts index 6ac2e956a0..f2ba2e6794 100644 --- a/packages/quicktype-core/src/language/CJSON/language.ts +++ b/packages/quicktype-core/src/language/CJSON/language.ts @@ -23,6 +23,7 @@ import type { RenderContext } from "../../Renderer.js"; import { + BooleanOption, EnumOption, StringOption, getOptionValues, @@ -117,6 +118,12 @@ export const cJSONOptions = { namingStyles, "upper-underscore-case", ), + headerOnly: new BooleanOption( + "header-only", + "Generate headers only", + true, + "secondary", + ), }; /* cJSON generator target language */ diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 356cc8acea..e0df375a41 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -16,7 +16,7 @@ import { import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinJacksonRenderer extends KotlinRenderer { private unionMemberJsonValueGuard(t: Type, _e: Sourcelike): Sourcelike { @@ -37,6 +37,7 @@ export class KotlinJacksonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is TextNode", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is TextNode", ); } @@ -76,7 +77,17 @@ import com.fasterxml.jackson.module.kotlin.*`); this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } @@ -84,6 +95,39 @@ import com.fasterxml.jackson.module.kotlin.*`); // if (hasEmptyObjects) { // converters.push([["convert(JsonNode::class,"], [" { it },"], [" { writeValueAsString(it) })"]]); // } + // We don't use jackson-datatype-jsr310's JavaTimeModule because its + // serializers don't round-trip faithfully (e.g. OffsetTime pads + // "23:20:50.52Z" to "23:20:50.520Z"); the ISO formatters do. + if (usesDateTime) { + converters.push([ + ["convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + ["convert(LocalDate::class,"], + [" { LocalDate.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + ["convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + this.forEachEnum("none", (_, name) => { converters.push([ ["convert(", name, "::class,"], @@ -334,19 +378,55 @@ private fun ObjectMapper.convert(k: kotlin.reflect.KClass<*>, fromJson: (Jso " = when (jn) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a node type + // (several transformed string types, or a transformed string + // type and an enum, are all TextNode) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jn")], - [" -> ", name, "(mapper.treeToValue(jn))"], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jn"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + let expr: Sourcelike = [ + ordered[ordered.length - 1].name, + "(mapper.treeToValue(jn))", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(mapper.treeToValue(jn)) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts index ac46686358..c3a0ef88ce 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts @@ -17,7 +17,7 @@ import { import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinKlaxonRenderer extends KotlinRenderer { private unionMemberFromJsonValue(t: Type, e: Sourcelike): Sourcelike { @@ -54,6 +54,21 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { ".fromValue(it) }", ], (_unionType) => mustNotHappen(), + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return [e, ".string?.let { OffsetDateTime.parse(it) }"]; + } + + if (transformedStringType.kind === "date") { + return [e, ".string?.let { LocalDate.parse(it) }"]; + } + + if (transformedStringType.kind === "time") { + return [e, ".string?.let { OffsetTime.parse(it) }"]; + } + + return [e, ".string"]; + }, ); } @@ -75,6 +90,7 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is String", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is String", ); } @@ -150,7 +166,17 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } @@ -160,6 +186,36 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { } const converters: Sourcelike[][] = []; + if (usesDateTime) { + converters.push([ + [".convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + [".convert(LocalDate::class,"], + [" { LocalDate.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + [".convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + if (hasEmptyObjects) { converters.push([ [".convert(JsonObject::class,"], @@ -455,25 +511,60 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { " = when (jv.inside) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a value type + // (several transformed string types, or a transformed string + // type and an enum, are all strings) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jv.inside")], - [ - " -> ", - name, - "(", - this.unionMemberFromJsonValue(t, "jv"), - "!!)", - ], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jv.inside"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + const last = ordered[ordered.length - 1]; + let expr: Sourcelike = [ + last.name, + "(", + this.unionMemberFromJsonValue(last.t, "jv"), + "!!)", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(", + this.unionMemberFromJsonValue(ordered[i].t, "jv"), + "!!) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index ba1f7c843c..a39af4d0fc 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -1,3 +1,5 @@ +import { iterableSome } from "collection-utils"; + import { anyTypeIssueAnnotation, nullTypeIssueAnnotation, @@ -19,7 +21,7 @@ import { type EnumType, MapType, type ObjectType, - type PrimitiveType, + PrimitiveType, type Type, type UnionType, } from "../../Type/index.js"; @@ -158,6 +160,15 @@ export class KotlinRenderer extends ConvenienceRenderer { ]; } + protected haveTransformedStringType( + kind: "date-time" | "date" | "time", + ): boolean { + return iterableSome( + this.typeGraph.allTypesUnordered(), + (t) => t instanceof PrimitiveType && t.kind === kind, + ); + } + protected kotlinType( t: Type, withIssues = false, @@ -194,6 +205,21 @@ export class KotlinRenderer extends ConvenienceRenderer { return [this.kotlinType(nullable, withIssues), optional]; return this.nameForNamedType(unionType); }, + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return "OffsetDateTime"; + } + + if (transformedStringType.kind === "date") { + return "LocalDate"; + } + + if (transformedStringType.kind === "time") { + return "OffsetTime"; + } + + return "String"; + }, ); } @@ -201,6 +227,12 @@ export class KotlinRenderer extends ConvenienceRenderer { // To be overridden } + // Emitted between the usage header and the package declaration — + // the only place Kotlin allows `@file:`-targeted annotations. + protected emitFileAnnotations(): void { + // To be overridden + } + protected emitHeader(): void { if (this.leadingComments !== undefined) { this.emitComments(this.leadingComments); @@ -209,8 +241,30 @@ export class KotlinRenderer extends ConvenienceRenderer { } this.ensureBlankLine(); + this.emitFileAnnotations(); this.emitLine("package ", this._kotlinOptions.packageName); this.ensureBlankLine(); + + // Check if we need to import java.time classes + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + + if (usesDateTime) { + this.emitLine("import java.time.OffsetDateTime"); + } + + if (usesDate) { + this.emitLine("import java.time.LocalDate"); + } + + if (usesTime) { + this.emitLine("import java.time.OffsetTime"); + } + + if (usesDateTime || usesDate || usesTime) { + this.ensureBlankLine(); + } } protected emitTopLevelPrimitive(t: PrimitiveType, name: Name): void { diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index e358f23aaa..99d789ed18 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -6,11 +6,51 @@ import type { ArrayType, EnumType, MapType, Type } from "../../Type/index.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import { stringEscape } from "./utils.js"; +// kotlinx.serialization has no built-in serializers for java.time, so we +// emit our own and register them file-wide with `@file:UseSerializers`. +// Like the Jackson converters, they parse with `.parse` and format with the +// ISO formatters so values round-trip faithfully. +const dateTimeSerializers = [ + { + kind: "date-time", + name: "OffsetDateTimeSerializer", + type: "OffsetDateTime", + formatter: "ISO_OFFSET_DATE_TIME", + }, + { + kind: "date", + name: "LocalDateSerializer", + type: "LocalDate", + formatter: "ISO_LOCAL_DATE", + }, + { + kind: "time", + name: "OffsetTimeSerializer", + type: "OffsetTime", + formatter: "ISO_OFFSET_TIME", + }, +] as const; + /** * Currently supports simple classes, enums, and TS string unions (which are also enums). * TODO: Union, Any, Top Level Array, Top Level Map */ export class KotlinXRenderer extends KotlinRenderer { + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return [ + ...super.forbiddenNamesForGlobalNamespace(), + ...dateTimeSerializers.map((s) => s.name), + ]; + } + + private usedDateTimeSerializers(): Array< + (typeof dateTimeSerializers)[number] + > { + return dateTimeSerializers.filter((s) => + this.haveTransformedStringType(s.kind), + ); + } + protected anySourceType(optional: string): Sourcelike { return ["JsonElement", optional]; } @@ -78,6 +118,18 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitTable(table); } + protected emitFileAnnotations(): void { + const serializers = this.usedDateTimeSerializers(); + if (serializers.length === 0) return; + + this.emitLine( + "@file:UseSerializers(", + serializers.map((s) => `${s.name}::class`).join(", "), + ")", + ); + this.ensureBlankLine(); + } + protected emitHeader(): void { super.emitHeader(); @@ -87,6 +139,21 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitLine("import kotlinx.serialization.encoding.*"); } + protected emitSourceStructure(): void { + super.emitSourceStructure(); + + for (const serializer of this.usedDateTimeSerializers()) { + this.ensureBlankLine(); + this.emitMultiline(`object ${serializer.name} : KSerializer<${serializer.type}> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("${serializer.type}", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ${serializer.type} = ${serializer.type}.parse(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: ${serializer.type}) { + encoder.encodeString(java.time.format.DateTimeFormatter.${serializer.formatter}.format(value)) + } +}`); + } + } + protected emitClassAnnotations(_c: Type, _className: Name): void { this.emitLine("@Serializable"); } diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index f806d423f9..28adca237c 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -1,4 +1,5 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; +import type { DateTimeRecognizer } from "../../DateTime.js"; import type { RenderContext } from "../../Renderer.js"; import { BooleanOption, @@ -9,12 +10,18 @@ import { import { AcronymStyleOptions, acronymOption } from "../../support/Acronyms.js"; import { assertNever } from "../../support/Support.js"; import { TargetLanguage } from "../../TargetLanguage.js"; +import type { + PrimitiveStringTypeKind, + TransformedStringTypeKind, +} from "../../Type/index.js"; +import type { StringTypeMapping } from "../../Type/TypeBuilderUtils.js"; import type { LanguageName, RendererOptions } from "../../types.js"; import { KotlinJacksonRenderer } from "./KotlinJacksonRenderer.js"; import { KotlinKlaxonRenderer } from "./KotlinKlaxonRenderer.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import { KotlinXRenderer } from "./KotlinXRenderer.js"; +import { KotlinDateTimeRecognizer } from "./utils.js"; export const kotlinOptions = { justTypes: new BooleanOption("just-types", "Plain types only", false), @@ -57,6 +64,21 @@ export class KotlinTargetLanguage extends TargetLanguage< return true; } + public get stringTypeMapping(): StringTypeMapping { + const mapping: Map = + new Map(); + mapping.set("date", "date"); + mapping.set("time", "time"); + mapping.set("date-time", "date-time"); + return mapping; + } + + // Only infer date/time types from JSON strings that java.time's ISO + // formatters round-trip byte-identically; see KotlinDateTimeRecognizer. + public get dateTimeRecognizer(): DateTimeRecognizer { + return new KotlinDateTimeRecognizer(); + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/Kotlin/utils.ts b/packages/quicktype-core/src/language/Kotlin/utils.ts index 0b667cfbea..6c20ebd47c 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,6 @@ +import { DefaultDateTimeRecognizer } from "../../DateTime.js"; +import type { Type } from "../../Type/index.js"; + import { allLowerWordStyle, allUpperWordStyle, @@ -55,3 +58,85 @@ export function stringEscape(s: string): string { // "$this" is a template string in Kotlin so we have to escape $ return _stringEscape(s).replace(/\$/g, "\\$"); } + +// The generated code round-trips date/time values through java.time's +// ISO_LOCAL_DATE / ISO_OFFSET_TIME / ISO_OFFSET_DATE_TIME, so we only +// recognize strings those formatters reproduce byte-identically (all +// verified against java.time): +// +// - Fractional seconds must not end in "0" — java.time formats the +// shortest fraction (".500" becomes ".5", ".000" disappears) — and +// have at most 9 digits, java.time's nanosecond precision. +// - The UTC offset must be "Z" or a nonzero "±hh:mm": zero offsets +// format back as "Z", and java.time rejects offsets beyond ±18:00. +// - Lowercase "t"/"z" format back in uppercase. +// - February 29 only exists in leap years; java.time refuses to parse +// it in other years (the default recognizer always allows it). +// +// Anything rejected here simply stays a plain string. This only affects +// inference from JSON samples — JSON Schema's "format": "date-time" is +// mapped regardless. +const KOTLIN_TIME = /^(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)$/; + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export class KotlinDateTimeRecognizer extends DefaultDateTimeRecognizer { + public isDate(str: string): boolean { + if (!super.isDate(str)) return false; + + const [year, month, day] = str.split("-").map(Number); + return month !== 2 || day !== 29 || isLeapYear(year); + } + + public isTime(str: string): boolean { + const matches = KOTLIN_TIME.exec(str); + if (matches === null) return false; + + const hour = +matches[1]; + const minute = +matches[2]; + const second = +matches[3]; + if (hour > 23 || minute > 59 || second > 59) return false; + + const fraction = matches[4]; + if (fraction?.endsWith("0")) return false; + + const offset = matches[5]; + if (offset === "Z") return true; + + const offsetHour = +offset.slice(1, 3); + const offsetMinute = +offset.slice(4, 6); + if (offsetHour === 0 && offsetMinute === 0) return false; + return offsetMinute <= 59 && offsetHour * 60 + offsetMinute <= 18 * 60; + } + + public isDateTime(str: string): boolean { + const dateTime = str.split("T"); + return ( + dateTime.length === 2 && + this.isDate(dateTime[0]) && + this.isTime(dateTime[1]) + ); + } +} + +// When several union members are guarded by the same JSON node/value type +// (e.g. date, time and enum values are all strings), parse attempts must be +// ordered strictest first: transformed string types (strict ISO parsers), +// then enums (known values only), then plain strings (accept anything). +export function unionMemberMatchPriority(t: Type): number { + if (t.kind === "date-time" || t.kind === "date" || t.kind === "time") { + return 0; + } + + if (t.kind === "enum") { + return 1; + } + + if (t.kind === "string") { + return 2; + } + + return 3; +} diff --git a/test/fixtures.ts b/test/fixtures.ts index d8bc01ed3f..a49aef802f 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -942,7 +942,9 @@ const commentInjectionTreeSitterTargets: TreeSitterTarget[] = [ { displayName: "cjson", language: languages.CJSONLanguage, - output: "TopLevel.c", + // CJSONLanguage renders with header-only=false, so this produces + // both TopLevel.h and TopLevel.c; both are collected and parsed. + output: "TopLevel.h", wasmModule: "tree-sitter-c/tree-sitter-c.wasm", extensions: [".c", ".h"], schema: commentInjectionSchema, @@ -1546,6 +1548,9 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.JavaLanguageWithLombok, "java-lombok"), new JSONFixture(languages.GoLanguage), new JSONFixture(languages.CJSONLanguage), + new JSONFixture(languages.CJSONDefaultLanguage, "cjson-default"), + new JSONFixture(languages.CJSONMultiHeaderLanguage, "cjson-multi-header"), + new JSONFixture(languages.CJSONMultiSplitLanguage, "cjson-multi-split"), new JSONFixture(languages.CPlusPlusLanguage), new JSONFixture(languages.PHPLanguage), new JSONFixture(languages.RustLanguage), diff --git a/test/fixtures/cjson/second.c b/test/fixtures/cjson/second.c new file mode 100644 index 0000000000..69df0916a6 --- /dev/null +++ b/test/fixtures/cjson/second.c @@ -0,0 +1,8 @@ +/* Second translation unit: including the generated header from more than + * one .c file must compile and link (no duplicate symbol definitions). */ + +#include "TopLevel.h" + +int quicktypeSecondTranslationUnit(void) { + return 0; +} diff --git a/test/languages.ts b/test/languages.ts index d1a7107354..b64b21cfca 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -482,16 +482,26 @@ export const GoLanguage: Language = { sourceFiles: ["src/language/Golang/index.ts"], }; +/* The vendored dependencies are downloaded into deps/ and included via + * -isystem so that generated cross-file includes must be quoted includes + * (resolved relative to the including file): a generated + * `#include ` fails to compile. */ +const cJSONSetupCommand = + "mkdir -p deps && curl -o deps/cJSON.c https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.15/cJSON.c && curl -o deps/cJSON.h https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.15/cJSON.h && curl -o deps/list.h https://raw.githubusercontent.com/joelguittet/c-list/master/include/list.h && curl -o deps/list.c https://raw.githubusercontent.com/joelguittet/c-list/master/src/list.c && curl -o deps/hashtable.h https://raw.githubusercontent.com/joelguittet/c-hashtable/master/include/hashtable.h && curl -o deps/hashtable.c https://raw.githubusercontent.com/joelguittet/c-hashtable/master/src/hashtable.c"; + +function cJSONRunCommand(sample: string): string { + return `valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes --error-exitcode=1 ./quicktype "${sample}"`; +} + export const CJSONLanguage: Language = { name: "cjson", base: "test/fixtures/cjson", - setupCommand: - "curl -o cJSON.c https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.15/cJSON.c && curl -o cJSON.h https://raw.githubusercontent.com/DaveGamble/cJSON/v1.7.15/cJSON.h && curl -o list.h https://raw.githubusercontent.com/joelguittet/c-list/master/include/list.h && curl -o list.c https://raw.githubusercontent.com/joelguittet/c-list/master/src/list.c && curl -o hashtable.h https://raw.githubusercontent.com/joelguittet/c-hashtable/master/include/hashtable.h && curl -o hashtable.c https://raw.githubusercontent.com/joelguittet/c-hashtable/master/src/hashtable.c", + setupCommand: cJSONSetupCommand, + /* second.c is a second translation unit including TopLevel.h; it verifies + * that the generated header/source split supports multi-TU builds. */ compileCommand: - "gcc -O0 -o quicktype -I. cJSON.c hashtable.c list.c main.c -lpthread", - runCommand(sample: string) { - return `valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes --error-exitcode=1 ./quicktype "${sample}"`; - }, + "gcc -O0 -o quicktype -isystem deps deps/cJSON.c deps/hashtable.c deps/list.c main.c second.c TopLevel.c -lpthread", + runCommand: cJSONRunCommand, diffViaSchema: true, skipDiffViaSchema: [ /* Enum constants are different when generating with schema */ @@ -566,8 +576,88 @@ export const CJSONLanguage: Language = { /* Class elements with invalid type are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ ...skipsUntypedUnions, ], + rendererOptions: { "header-only": "false" }, + quickTestRendererOptions: [ + { "source-style": "single-source", "header-only": "false" }, + ], + sourceFiles: ["src/language/CJSON/index.ts"], +}; + +/* Minimal fixtures covering the remaining source-style / header-only mode + * combinations on a single complex input (enums, unions, many classes). + * They share the cjson driver directory and setup. */ + +/* Default options: single-source, header-only. Single translation unit, + * no generated TopLevel.c — the pre-existing output mode. */ +export const CJSONDefaultLanguage: Language = { + name: "cjson", + base: "test/fixtures/cjson", + setupCommand: cJSONSetupCommand, + compileCommand: + "gcc -O0 -o quicktype -isystem deps deps/cJSON.c deps/hashtable.c deps/list.c main.c -lpthread", + runCommand: cJSONRunCommand, + diffViaSchema: false, + skipDiffViaSchema: [], + allowMissingNull: false, + features: [], + output: "TopLevel.h", + topLevel: "TopLevel", + includeJSON: ["nbl-stats.json"], + skipMiscJSON: true, + skipSchema: [], rendererOptions: {}, - quickTestRendererOptions: [{ "source-style": "single-source" }], + quickTestRendererOptions: [], + sourceFiles: ["src/language/CJSON/index.ts"], +}; + +/* Multi-source, header-only. One header per type; still a single + * translation unit, since header-only output defines functions in the + * headers and cannot link from multiple translation units. */ +export const CJSONMultiHeaderLanguage: Language = { + name: "cjson", + base: "test/fixtures/cjson", + setupCommand: cJSONSetupCommand, + compileCommand: + "gcc -O0 -o quicktype -isystem deps deps/cJSON.c deps/hashtable.c deps/list.c main.c -lpthread", + runCommand: cJSONRunCommand, + diffViaSchema: false, + skipDiffViaSchema: [], + allowMissingNull: false, + features: [], + output: "TopLevel.h", + topLevel: "TopLevel", + includeJSON: ["nbl-stats.json"], + skipMiscJSON: true, + skipSchema: [], + rendererOptions: { "source-style": "multi-source" }, + quickTestRendererOptions: [], + sourceFiles: ["src/language/CJSON/index.ts"], +}; + +/* Multi-source, split header/source pairs. The wildcard picks up main.c, + * second.c and every generated .c file; linking the two translation units + * verifies the core promise of the split mode (issue #2617). */ +export const CJSONMultiSplitLanguage: Language = { + name: "cjson", + base: "test/fixtures/cjson", + setupCommand: cJSONSetupCommand, + compileCommand: + "gcc -O0 -o quicktype -isystem deps deps/cJSON.c deps/hashtable.c deps/list.c *.c -lpthread", + runCommand: cJSONRunCommand, + diffViaSchema: false, + skipDiffViaSchema: [], + allowMissingNull: false, + features: [], + output: "TopLevel.h", + topLevel: "TopLevel", + includeJSON: ["nbl-stats.json"], + skipMiscJSON: true, + skipSchema: [], + rendererOptions: { + "source-style": "multi-source", + "header-only": "false", + }, + quickTestRendererOptions: [], sourceFiles: ["src/language/CJSON/index.ts"], }; @@ -1119,7 +1209,7 @@ export const KotlinLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1209,7 +1299,7 @@ export const KotlinJacksonLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1301,7 +1391,11 @@ export const KotlinXLanguage: Language = { // No "union": the kotlinx renderer emits unions as sealed classes // without any serializer wiring, so they don't (de)serialize // (documented TODO in KotlinXRenderer.ts). - features: ["enum", "no-defaults"], + // "date-time" is supported via emitted KSerializers, but note that + // date-time.schema itself stays skipped: its union-array properties + // hit the union limitation above. The serializers are exercised by + // the JSON inputs with inferred date-times instead. + features: ["enum", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1386,6 +1480,9 @@ export const KotlinXLanguage: Language = { "class-map-union.schema", "class-with-additional.schema", "date-time.schema", + // The string|date-time property becomes a union once Kotlin maps + // date-time (it was a plain string before). + "date-time-or-string.schema", "description.schema", "direct-union.schema", "enum.schema", // enum.3.json contains an int|string union diff --git a/test/unit/cjson-split-sources.test.ts b/test/unit/cjson-split-sources.test.ts new file mode 100644 index 0000000000..b640c41e23 --- /dev/null +++ b/test/unit/cjson-split-sources.test.ts @@ -0,0 +1,124 @@ +// cJSON can split its output into header/source pairs (header-only=false) +// and can emit one file per type (source-style=multi-source). The first +// version of the split emitted a `#include ` self-include at +// the top of every generated source file (an unguarded self-include that +// recurses at compile time), referenced generated headers with angle +// brackets instead of the quoted-include convention, and made every header +// include itself in the pre-existing multi-source header-only mode. These +// tests pin down the include structure of the generated files. +import { describe, expect, test } from "vitest"; + +import { + InputData, + type RendererOptions, + jsonInputForTargetLanguage, + quicktypeMultiFile, +} from "quicktype-core"; + +async function cJSONFiles( + rendererOptions: RendererOptions, + outputFilename = "TopLevel.h", +): Promise> { + const jsonInput = jsonInputForTargetLanguage("cjson"); + await jsonInput.addSource({ + name: "TopLevel", + samples: [ + '{"child": {"n": 1}, "color": "red", "value": 1}', + '{"child": {"n": 2}, "color": "green", "value": "s"}', + ], + }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktypeMultiFile({ + inputData, + lang: "cjson", + outputFilename, + rendererOptions, + }); + return new Map( + Array.from(result, ([filename, serialized]) => [ + filename, + serialized.lines.join("\n"), + ]), + ); +} + +function includesIn(source: string): string[] { + return source.match(/#include [<"][^>"]+[>"]/g) ?? []; +} + +describe("cJSON multi-source header/source pairs", () => { + const rendererOptions: RendererOptions = { + "source-style": "multi-source", + "header-only": false, + }; + + test("every header gets a source file", async () => { + const files = await cJSONFiles(rendererOptions); + const names = Array.from(files.keys()); + const headers = names.filter((name) => name.endsWith(".h")); + expect(headers.length).toBeGreaterThan(2); + for (const header of headers) { + expect(names).toContain(header.replace(/\.h$/, ".c")); + } + }); + + test("no generated file includes itself", async () => { + const files = await cJSONFiles(rendererOptions); + for (const [filename, source] of files) { + expect(includesIn(source)).not.toContain(`#include "${filename}"`); + expect(includesIn(source)).not.toContain(`#include <${filename}>`); + } + }); + + test("generated files are included with quotes, not angle brackets", async () => { + const files = await cJSONFiles(rendererOptions); + for (const [, source] of files) { + for (const include of includesIn(source)) { + const match = /#include <([^>]+)>/.exec(include); + if (match === null) { + continue; + } + + // Angle brackets are reserved for system and vendored + // headers; a generated file must never appear in them. + expect(files.has(match[1])).toBe(false); + } + } + }); + + test("each source file includes its own header first", async () => { + const files = await cJSONFiles(rendererOptions); + for (const [filename, source] of files) { + if (!filename.endsWith(".c")) { + continue; + } + + const header = filename.replace(/\.c$/, ".h"); + expect(includesIn(source)[0]).toBe(`#include "${header}"`); + } + }); +}); + +describe("cJSON multi-source header-only mode", () => { + test("emits no source files and no header includes itself", async () => { + const files = await cJSONFiles({ "source-style": "multi-source" }); + expect(files.size).toBeGreaterThan(2); + for (const [filename, source] of files) { + expect(filename).toMatch(/\.h$/); + expect(includesIn(source)).not.toContain(`#include "${filename}"`); + expect(includesIn(source)).not.toContain(`#include <${filename}>`); + } + }); +}); + +describe("cJSON source filename derivation", () => { + test("only a trailing .h is swapped for .c", async () => { + // `.replace(".h", ".c")` would have produced "my.couse.h". + const files = await cJSONFiles({ "header-only": false }, "my.house.h"); + expect(Array.from(files.keys()).sort()).toEqual([ + "my.house.c", + "my.house.h", + ]); + }); +}); diff --git a/test/unit/date-time-recognizer.test.ts b/test/unit/date-time-recognizer.test.ts index dee7e5396c..5ee1e52b3c 100644 --- a/test/unit/date-time-recognizer.test.ts +++ b/test/unit/date-time-recognizer.test.ts @@ -6,6 +6,7 @@ // producing generated code whose strict date parsers (Go, Swift, java.time, // ...) could not read the very samples the types were inferred from. import { DefaultDateTimeRecognizer } from "quicktype-core/dist/DateTime.js"; +import { KotlinDateTimeRecognizer } from "quicktype-core/dist/language/Kotlin/utils.js"; import { describe, expect, test } from "vitest"; const recognizer = new DefaultDateTimeRecognizer(); @@ -83,3 +84,70 @@ describe("isTime", () => { expect(recognizer.isTime("02:45:60Z")).toBe(false); }); }); + +// The Kotlin recognizer additionally requires that java.time's ISO +// formatters (which the generated Kotlin code round-trips through) +// reproduce the string byte-identically. All rules below were verified +// against java.time's actual parse/format behavior. +describe("KotlinDateTimeRecognizer", () => { + const kotlin = new KotlinDateTimeRecognizer(); + + test("accepts date-times java.time round-trips identically", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50Z")).toBe(true); + expect(kotlin.isDateTime("2010-12-01T00:00:00Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+05:30")).toBe(true); + expect(kotlin.isDateTime("2015-04-24T01:46:50.342496Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.5Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.123456789Z")).toBe(true); + expect(kotlin.isDateTime("2016-02-29T00:00:00Z")).toBe(true); + }); + + test("rejects fractional seconds with trailing zeros", () => { + // java.time formats the shortest fraction: ".000" disappears, + // ".500" becomes ".5", ".828000" becomes ".828". + expect(kotlin.isDateTime("2010-01-12T00:00:00.000Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50.50Z")).toBe(false); + expect(kotlin.isDateTime("2008-09-10T13:21:30.828000Z")).toBe(false); + expect(kotlin.isDateTime("2015-04-24T02:04:02.997570Z")).toBe(false); + }); + + test("rejects fractions beyond nanosecond precision", () => { + // java.time refuses to parse more than 9 fractional digits. + expect(kotlin.isDateTime("2018-08-14T02:45:50.1234567891Z")).toBe( + false, + ); + }); + + test("rejects zero offsets, which format back as Z", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+00:00")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50-00:00")).toBe(false); + expect(kotlin.isTime("10:30:00+00:00")).toBe(false); + }); + + test("rejects offsets outside java.time's ±18:00 range", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50-18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:01")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50+19:00")).toBe(false); + }); + + test("rejects lowercase t and z, which format back in uppercase", () => { + expect(kotlin.isDateTime("2018-08-14t02:45:50Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50z")).toBe(false); + expect(kotlin.isTime("02:45:50z")).toBe(false); + }); + + test("rejects Feb 29 outside leap years, which java.time cannot parse", () => { + expect(kotlin.isDate("2016-02-29")).toBe(true); + expect(kotlin.isDate("2000-02-29")).toBe(true); + expect(kotlin.isDate("2015-02-29")).toBe(false); + expect(kotlin.isDate("1900-02-29")).toBe(false); + expect(kotlin.isDateTime("2015-02-29T00:00:00Z")).toBe(false); + }); + + test("accepts times java.time round-trips identically", () => { + expect(kotlin.isTime("02:45:50Z")).toBe(true); + expect(kotlin.isTime("23:20:50.52Z")).toBe(true); + expect(kotlin.isTime("23:20:50.520Z")).toBe(false); + }); +}); diff --git a/test/unit/kotlinx-datetime-serializers.test.ts b/test/unit/kotlinx-datetime-serializers.test.ts new file mode 100644 index 0000000000..083cfc7ebb --- /dev/null +++ b/test/unit/kotlinx-datetime-serializers.test.ts @@ -0,0 +1,85 @@ +// Kotlin's language-wide stringTypeMapping maps JSON Schema "date", +// "time", and "date-time" formats to java.time types for all frameworks. +// kotlinx.serialization has no built-in serializers for java.time, so the +// kotlinx renderer must emit custom KSerializer objects and register them +// with a `@file:UseSerializers(...)` annotation — otherwise the generated +// code doesn't compile ("Serializer has not been found for type +// 'OffsetDateTime'"). There is no kotlinx fixture in CI, so this unit test +// covers it. + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function kotlinxOutput( + properties: Record, +): Promise { + const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-06/schema#", + type: "object", + properties, + required: Object.keys(properties), + }); + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "kotlin", + rendererOptions: { framework: "kotlinx" }, + }); + return result.lines.join("\n"); +} + +describe("kotlinx date/time serializers", () => { + test("emits KSerializers and @file:UseSerializers for date/time types", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + time: { type: "string", format: "time" }, + dateTime: { type: "string", format: "date-time" }, + }); + + expect(output).toContain( + "@file:UseSerializers(OffsetDateTimeSerializer::class, LocalDateSerializer::class, OffsetTimeSerializer::class)", + ); + for (const [serializer, javaType] of [ + ["OffsetDateTimeSerializer", "OffsetDateTime"], + ["LocalDateSerializer", "LocalDate"], + ["OffsetTimeSerializer", "OffsetTime"], + ]) { + expect(output).toContain( + `object ${serializer} : KSerializer<${javaType}>`, + ); + expect(output).toContain(`import java.time.${javaType}`); + } + + // The file annotation must precede the package declaration. + expect(output.indexOf("@file:UseSerializers")).toBeLessThan( + output.indexOf("package "), + ); + }); + + test("emits only the serializers that are used", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + }); + + expect(output).toContain( + "@file:UseSerializers(LocalDateSerializer::class)", + ); + expect(output).toContain("object LocalDateSerializer"); + expect(output).not.toContain("OffsetDateTimeSerializer"); + expect(output).not.toContain("OffsetTimeSerializer"); + }); + + test("emits no serializer machinery without date/time types", async () => { + const output = await kotlinxOutput({ + name: { type: "string" }, + }); + + expect(output).not.toContain("@file:UseSerializers"); + expect(output).not.toContain("KSerializer"); + expect(output).not.toContain("java.time"); + }); +});