From e498c3c58c25bdbb62bb433174d4db5c06a13d0a Mon Sep 17 00:00:00 2001 From: thives Date: Sat, 15 Jun 2024 00:14:06 +0200 Subject: [PATCH 01/13] cJSON - split generated code into source and header files --- .../src/language/CJSON/CJSONRenderer.ts | 176 +++++++++++++----- test/languages.ts | 2 +- 2 files changed, 130 insertions(+), 48 deletions(-) diff --git a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts index 6f9dc33b9b..b458b7b669 100644 --- a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts +++ b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts @@ -28,7 +28,9 @@ import { } from "./utils"; 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 */ @@ -170,8 +172,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 */ @@ -183,12 +185,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 => { @@ -237,6 +239,12 @@ export class CJSONRenderer extends ConvenienceRenderer { type => this.namedTypeToNameForTopLevel(type) === undefined ); + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(proposedFilename); + /* Create enum functions */ this.forEachEnum("leading-and-interposing", (enumType: EnumType, _enumName: Name) => this.emitEnumFunctions(enumType) @@ -257,8 +265,8 @@ export class CJSONRenderer extends ConvenienceRenderer { type => this.namedTypeToNameForTopLevel(type) === undefined ); - /* Close file */ - this.finishFile(); + /* Close source file */ + this.finishSourceFile(); } /** @@ -298,12 +306,13 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitEnum(enumType: EnumType, includes: string[]): void { /* Create file */ const enumName = this.nameForNamedType(enumType); - const filename = this.sourcelikeToString(enumName).concat(".h"); - includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(enumName).concat(".h"); + const sourceFilename = this.getSourceNameFromHeaderName(headerFilename); + 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); @@ -311,11 +320,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitEnumPrototypes(enumType); + /* Close header file and create source file */ + this.finishHeaderFile(); + this.startSourceFile(sourceFilename); + + /* Include corresponding header file */ + this.emitIncludeLine(headerFilename, true); + /* Create functions */ this.emitEnumFunctions(enumType); - /* Close file */ - this.finishFile(); + /* Close source file */ + this.finishSourceFile(); } /** @@ -426,12 +442,13 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitUnion(unionType: UnionType, includes: string[]): void { /* Create file */ const unionName = this.nameForNamedType(unionType); - const filename = this.sourcelikeToString(unionName).concat(".h"); - includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(unionName).concat(".h"); + const sourceFilename = this.getSourceNameFromHeaderName(headerFilename); + 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); @@ -439,11 +456,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitUnionPrototypes(unionType); + /* Close header file and create source file */ + this.finishHeaderFile(); + this.startSourceFile(sourceFilename); + + /* Include corresponding header file */ + this.emitIncludeLine(headerFilename, true); + /* Create functions */ this.emitUnionFunctions(unionType); - /* Close file */ - this.finishFile(); + /* Close source file */ + this.finishSourceFile(); } /** @@ -1329,12 +1353,13 @@ export class CJSONRenderer extends ConvenienceRenderer { protected emitClass(classType: ClassType, includes: string[]): void { /* Create file */ const className = this.nameForNamedType(classType); - const filename = this.sourcelikeToString(className).concat(".h"); - includes.push(filename); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(className).concat(".h"); + const sourceFilename = this.getSourceNameFromHeaderName(headerFilename); + 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); @@ -1342,11 +1367,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitClassPrototypes(classType); + /* Close header file and create source file */ + this.finishHeaderFile(); + this.startSourceFile(sourceFilename); + + /* Include corresponding header file */ + this.emitIncludeLine(headerFilename, true); + /* Create functions */ this.emitClassFunctions(classType); - /* Close file */ - this.finishFile(); + /* Close source file */ + this.finishSourceFile(); } /** @@ -2949,8 +2981,9 @@ export class CJSONRenderer extends ConvenienceRenderer { */ protected emitTopLevel(type: Type, className: Name, includes: string[]): void { /* Create file */ - const filename = this.sourcelikeToString(className).concat(".h"); - this.startFile(filename); + const headerFilename = this.sourcelikeToString(className).concat(".h"); + const sourceFilename = this.getSourceNameFromHeaderName(headerFilename); + this.startHeaderFile(headerFilename); /* Create includes - This create too much includes but this is safer because of specific corner cases */ includes.forEach(name => { @@ -2964,11 +2997,18 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitTopLevelPrototypes(type, className); + /* Close header file and create source file */ + this.finishHeaderFile(); + this.startSourceFile(sourceFilename); + + /* Include corresponding header file */ + this.emitIncludeLine(headerFilename, true); + /* Create functions */ this.emitTopLevelFunctions(type, className); - /* Close file */ - this.finishFile(); + /* Close source file */ + this.finishSourceFile(); } /** @@ -3564,21 +3604,21 @@ 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 */ - assert(this.currentFilename === undefined, "Previous file wasn't finished"); + protected startHeaderFile(proposedFilename: Sourcelike): void { + /* Check if previous header file is closed, create a new file */ + assert(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();", @@ -3592,12 +3632,12 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Write include guard */ this.emitLine( "#ifndef __", - allUpperWordStyle(this.currentFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), + allUpperWordStyle(this.currentHeaderFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), "__" ); this.emitLine( "#define __", - allUpperWordStyle(this.currentFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), + allUpperWordStyle(this.currentHeaderFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), "__" ); this.ensureBlankLine(); @@ -3632,12 +3672,36 @@ export class CJSONRenderer extends ConvenienceRenderer { } } + /** + * Function called to create a source file + * @param proposedFilename: source filename provided from stdin + */ + protected startSourceFile(proposedFilename: Sourcelike): void { + /* Check if previous source file is closed, create a new file */ + assert(this.currentSourceFilename === undefined, "Previous source file wasn't finished"); + if (proposedFilename !== undefined) { + this.currentSourceFilename = this.getSourceNameFromHeaderName(this.sourcelikeToString(proposedFilename)); + } + + /* Check if source file and corresponding header 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(); + this.emitIncludeLine(this.sourcelikeToString(proposedFilename), true); + this.ensureBlankLine(); + } + } + /** * Function called to close current 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("}"); @@ -3647,14 +3711,28 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Write include guard */ this.emitLine( "#endif /* __", - allUpperWordStyle(this.currentFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), + allUpperWordStyle(this.currentHeaderFilename.replace(new RegExp(/[^a-zA-Z0-9]+/, "g"), "_")), "__ */" ); this.ensureBlankLine(); - /* Close file */ - super.finishFile(defined(this.currentFilename)); - this.currentFilename = undefined; + /* Close headeerfile */ + 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; } } @@ -3886,4 +3964,8 @@ export class CJSONRenderer extends ConvenienceRenderer { recur(false, false, 0, type); return result; } + + protected getSourceNameFromHeaderName(headerName: string): string { + return headerName.replace(".h", ".c"); + } } diff --git a/test/languages.ts b/test/languages.ts index f1890a7d54..5fcc476989 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -413,7 +413,7 @@ export const CJSONLanguage: Language = { 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", - compileCommand: "gcc -O0 -o quicktype -I. cJSON.c hashtable.c list.c main.c -lpthread", + compileCommand: "gcc -O0 -o quicktype -I. cJSON.c hashtable.c list.c main.c TopLevel.c -lpthread", runCommand(sample: string) { return `valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes --error-exitcode=1 ./quicktype "${sample}"`; }, From 103bead9294e5c7fe63e39511bc94c0393e019b8 Mon Sep 17 00:00:00 2001 From: thives Date: Sat, 15 Jun 2024 20:50:39 +0200 Subject: [PATCH 02/13] cJSON - add header-only option (default true) to opt in split header and source code generation --- .../src/language/CJSON/CJSONRenderer.ts | 95 ++++++++++++++----- .../src/language/CJSON/language.ts | 15 ++- test/languages.ts | 4 +- 3 files changed, 84 insertions(+), 30 deletions(-) diff --git a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts index b458b7b669..c3e2b416fc 100644 --- a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts +++ b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts @@ -239,11 +239,13 @@ export class CJSONRenderer extends ConvenienceRenderer { type => this.namedTypeToNameForTopLevel(type) === undefined ); - /* Close header file */ - this.finishHeaderFile(); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); - /* Create source file */ - this.startSourceFile(proposedFilename); + /* Create source file */ + this.startSourceFile(proposedFilename); + } /* Create enum functions */ this.forEachEnum("leading-and-interposing", (enumType: EnumType, _enumName: Name) => @@ -265,8 +267,13 @@ export class CJSONRenderer extends ConvenienceRenderer { type => this.namedTypeToNameForTopLevel(type) === undefined ); - /* Close source file */ - this.finishSourceFile(); + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); + } } /** @@ -320,9 +327,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitEnumPrototypes(enumType); - /* Close header file and create source file */ - this.finishHeaderFile(); - this.startSourceFile(sourceFilename); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(sourceFilename); + } /* Include corresponding header file */ this.emitIncludeLine(headerFilename, true); @@ -330,8 +341,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create functions */ this.emitEnumFunctions(enumType); - /* Close source file */ - this.finishSourceFile(); + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); + } } /** @@ -456,9 +472,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitUnionPrototypes(unionType); - /* Close header file and create source file */ - this.finishHeaderFile(); - this.startSourceFile(sourceFilename); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(sourceFilename); + } /* Include corresponding header file */ this.emitIncludeLine(headerFilename, true); @@ -466,8 +486,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create functions */ this.emitUnionFunctions(unionType); - /* Close source file */ - this.finishSourceFile(); + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); + } } /** @@ -1367,9 +1392,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitClassPrototypes(classType); - /* Close header file and create source file */ - this.finishHeaderFile(); - this.startSourceFile(sourceFilename); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(sourceFilename); + } /* Include corresponding header file */ this.emitIncludeLine(headerFilename, true); @@ -1377,8 +1406,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create functions */ this.emitClassFunctions(classType); - /* Close source file */ - this.finishSourceFile(); + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); + } } /** @@ -2997,9 +3031,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create prototypes */ this.emitTopLevelPrototypes(type, className); - /* Close header file and create source file */ - this.finishHeaderFile(); - this.startSourceFile(sourceFilename); + if (!this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + + /* Create source file */ + this.startSourceFile(sourceFilename); + } /* Include corresponding header file */ this.emitIncludeLine(headerFilename, true); @@ -3007,8 +3045,13 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create functions */ this.emitTopLevelFunctions(type, className); - /* Close source file */ - this.finishSourceFile(); + if (this._options.headerOnly) { + /* Close header file */ + this.finishHeaderFile(); + } else { + /* Close source file */ + this.finishSourceFile(); + } } /** diff --git a/packages/quicktype-core/src/language/CJSON/language.ts b/packages/quicktype-core/src/language/CJSON/language.ts index 82e32ffd90..376be3ee74 100644 --- a/packages/quicktype-core/src/language/CJSON/language.ts +++ b/packages/quicktype-core/src/language/CJSON/language.ts @@ -110,7 +110,17 @@ export const cJSONOptions = { camelValue, pascalUpperAcronymsValue, camelUpperAcronymsValue - ]) + ]), + headerOnly: new EnumOption( + "header-only", + "Generate headers only", + [ + ["true", true], + ["false", false] + ], + "true", + "secondary" + ) }; /* cJSON generator target language */ @@ -138,7 +148,8 @@ export class CJSONTargetLanguage extends TargetLanguage { cJSONOptions.hashtableSize, cJSONOptions.typeNamingStyle, cJSONOptions.memberNamingStyle, - cJSONOptions.enumeratorNamingStyle + cJSONOptions.enumeratorNamingStyle, + cJSONOptions.headerOnly ]; } diff --git a/test/languages.ts b/test/languages.ts index 5fcc476989..96a43c91dc 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -481,8 +481,8 @@ export const CJSONLanguage: Language = { /* Other cases not supported */ "implicit-class-array-union.schema" ], - rendererOptions: {}, - quickTestRendererOptions: [{ "source-style": "single-source" }], + rendererOptions: { "header-only": "false" }, + quickTestRendererOptions: [{ "source-style": "single-source", "header-only": "false" }], sourceFiles: ["src/language/CJSON/index.ts"] }; From fa3b20990490b8160ee62b27fd5afba023c5eb78 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Fri, 31 Oct 2025 08:38:26 -0400 Subject: [PATCH 03/13] feat(kotlin): Support for date and datetime from JSON schema --- .../language/Kotlin/KotlinJacksonRenderer.ts | 1 + .../src/language/Kotlin/KotlinRenderer.ts | 49 ++++++++++++++++++- .../src/language/Kotlin/language.ts | 14 ++++++ test/languages.ts | 4 +- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 3401ed1fe3..c7c9b6532b 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -49,6 +49,7 @@ export class KotlinJacksonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is TextNode", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is TextNode", ); } diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index 5e1c45b6b2..544845b709 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"; @@ -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"; + }, ); } @@ -211,6 +237,27 @@ export class KotlinRenderer extends ConvenienceRenderer { this.ensureBlankLine(); 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/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index ce72896acf..3b0e03677f 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -8,6 +8,11 @@ import { import { AcronymStyleOptions, acronymOption } from "../../support/Acronyms"; import { assertNever } from "../../support/Support"; import { TargetLanguage } from "../../TargetLanguage"; +import type { + PrimitiveStringTypeKind, + TransformedStringTypeKind, +} from "../../Type"; +import type { StringTypeMapping } from "../../Type/TypeBuilderUtils"; import type { LanguageName, RendererOptions } from "../../types"; import { KotlinJacksonRenderer } from "./KotlinJacksonRenderer"; @@ -56,6 +61,15 @@ 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; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/test/languages.ts b/test/languages.ts index 9573f3e574..14855564d6 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1093,7 +1093,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: [ @@ -1178,7 +1178,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: [ From 3d45b9f5e7c0a564fe95b3e91d2a86d9ae75cf93 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 04/13] Kotlin/Jackson: make java.time date/time round-trip work Register strict ISO converters on the generated ObjectMapper for OffsetDateTime, LocalDate and OffsetTime via the existing convert() infrastructure instead of requiring jackson-datatype-jsr310: the module's serializers don't round-trip faithfully (OffsetTime pads "23:20:50.52Z" to "23:20:50.520Z"), while the ISO formatters do, and this way generated code needs no extra dependency. Also fix union deserialization when several members are guarded by the same node type: date, time, date-time and enum values are all TextNode, so the previous output had duplicate 'is TextNode ->' branches of which only the first could ever match. Members sharing a guard now parse in a single branch that tries each member in sequence, strictest first (transformed strings, then enums, then plain strings). Co-Authored-By: Claude Fable 5 --- .../language/Kotlin/KotlinJacksonRenderer.ts | 93 +++++++++++++++++-- .../src/language/Kotlin/utils.ts | 22 +++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 6382b62003..f32b04a8d5 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -20,7 +20,7 @@ import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinJacksonRenderer extends KotlinRenderer { public constructor( @@ -89,7 +89,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(); } @@ -97,6 +107,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,"], @@ -347,19 +390,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/utils.ts b/packages/quicktype-core/src/language/Kotlin/utils.ts index 927d21de80..b1ffc7e015 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,5 @@ +import type { Type } from "../../Type/index.js"; + import { allLowerWordStyle, allUpperWordStyle, @@ -55,3 +57,23 @@ export function stringEscape(s: string): string { // "$this" is a template string in Kotlin so we have to escape $ return _stringEscape(s).replace(/\$/g, "\\$"); } + +// 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; +} From 2d37aeac5548e686d8efb2848acbdba1b1ae2146 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 05/13] Kotlin/Klaxon: support date/time types from JSON schema The klaxon renderer's matchType calls were missing the transformedStringType matcher the base renderer maps to java.time types, so any schema with date/time formats in a union crashed generation with "Unsupported type date-time in non-exhaustive match". Add the matchers, register Klaxon converters that parse the java.time types from ISO strings and re-serialize them via the ISO formatters (exact round-trip), and give union members that share the 'is String' guard the same sequential-parse treatment as the Jackson renderer. Co-Authored-By: Claude Fable 5 --- .../language/Kotlin/KotlinKlaxonRenderer.ts | 117 ++++++++++++++++-- 1 file changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts index 8b8ba8cd48..5223d7b184 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts @@ -20,7 +20,7 @@ import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinKlaxonRenderer extends KotlinRenderer { public constructor( @@ -65,6 +65,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"]; + }, ); } @@ -86,6 +101,7 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is String", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is String", ); } @@ -116,11 +132,51 @@ 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(); } 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,"], @@ -376,25 +432,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([ From 68516b428f1aef8ca6ae8f538831e50b16c4afec Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 06/13] Kotlin fixtures: skip misc JSONs whose date-times can't round-trip java.time With date/time inference now mapping to java.time types, two groups of misc inputs fail the round-trip comparison: files with non-RFC3339 date-times (space-separated, no offset) that OffsetDateTime.parse rejects, and files whose fractional seconds have trailing zeros that the ISO formatters legitimately trim (".000Z" -> "Z"). Skip them for both Kotlin variants, mirroring what Go already does for the first group. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 825ca10a92..9bd62a4a1e 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1182,6 +1182,27 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", + // Contain date-time values in non-RFC3339 formats that java.time + // cannot parse + "437e7.json", + "127a1.json", + "e0ac7.json", + "7681c.json", + "26b49.json", + "f6a65.json", + "0cffa.json", + "c3303.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + "54d32.json", + "77392.json", + "0a358.json", + "80aff.json", + "b4865.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1267,6 +1288,27 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", + // Contain date-time values in non-RFC3339 formats that java.time + // cannot parse + "437e7.json", + "127a1.json", + "e0ac7.json", + "7681c.json", + "26b49.json", + "f6a65.json", + "0cffa.json", + "c3303.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + "54d32.json", + "77392.json", + "0a358.json", + "80aff.json", + "b4865.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, From c1946947174db33a8e62389c7bf4db05ae6bf7d0 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 20:07:00 -0400 Subject: [PATCH 07/13] Drop kotlin skips for non-RFC3339 date-times, obsoleted by strict inference With the default recognizer now requiring RFC 3339 (T separator and timezone offset), space-separated timestamps like "2013-06-15 21:10:28" are inferred as plain strings, so these eight misc fixtures round-trip for both kotlin variants without skips (verified per file; f6a65.json also verified end-to-end with kotlinc). The nine remaining skips are unchanged: their values are RFC 3339-valid but carry trailing-zero fractional seconds (e.g. ".000Z") that java.time does not re-serialize identically. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index c37c7a2127..67302b9884 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1152,16 +1152,6 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values in non-RFC3339 formats that java.time - // cannot parse - "437e7.json", - "127a1.json", - "e0ac7.json", - "7681c.json", - "26b49.json", - "f6a65.json", - "0cffa.json", - "c3303.json", // Contain date-time values with trailing zeros in the fractional // seconds, which java.time does not re-serialize identically "54d32.json", @@ -1258,16 +1248,6 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values in non-RFC3339 formats that java.time - // cannot parse - "437e7.json", - "127a1.json", - "e0ac7.json", - "7681c.json", - "26b49.json", - "f6a65.json", - "0cffa.json", - "c3303.json", // Contain date-time values with trailing zeros in the fractional // seconds, which java.time does not re-serialize identically "54d32.json", From 9b3fffef0a9e9dfc015a961697dc24558ea86d5b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 12:07:35 -0400 Subject: [PATCH 08/13] fix(kotlin): emit kotlinx serializers for java.time types The Kotlin language's stringTypeMapping maps JSON Schema date, time, and date-time formats to java.time types for every framework, but the kotlinx renderer was never taught about them: it emitted @Serializable data classes with OffsetDateTime/LocalDate/OffsetTime properties, which don't compile because kotlinx.serialization has no built-in serializers for java.time ("Serializer has not been found for type 'OffsetDateTime'"). Emit custom KSerializer objects for the java.time types that appear in the type graph and register them file-wide with @file:UseSerializers. Like the Jackson and Klaxon converters, they parse with .parse and format with the ISO formatters so values round-trip faithfully. There is no kotlinx fixture in CI, so cover this with a unit test asserting the serializers and file annotation are emitted (and not emitted when no date/time types occur). Co-Authored-By: Claude Fable 5 --- .../src/language/Kotlin/KotlinRenderer.ts | 7 ++ .../src/language/Kotlin/KotlinXRenderer.ts | 67 +++++++++++++++ .../unit/kotlinx-datetime-serializers.test.ts | 85 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 test/unit/kotlinx-datetime-serializers.test.ts diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index 52e8ae35c3..5b238ae934 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -227,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); @@ -235,6 +241,7 @@ export class KotlinRenderer extends ConvenienceRenderer { } this.ensureBlankLine(); + this.emitFileAnnotations(); this.emitLine("package ", this._kotlinOptions.packageName); this.ensureBlankLine(); diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index 981204bf48..cf1bea8fab 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -15,6 +15,31 @@ import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.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 @@ -28,6 +53,21 @@ export class KotlinXRenderer extends KotlinRenderer { super(targetLanguage, renderContext, _kotlinOptions); } + 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]; } @@ -95,6 +135,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(); @@ -104,6 +156,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/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"); + }); +}); From 347969baed505a7b5c8fb5ae5f71b56574c3a108 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 13:34:39 -0400 Subject: [PATCH 09/13] test(kotlinx): enable date-time feature and adjust skips for date inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging master brought in the kotlinx fixture coverage from #2950, whose skip lists were tuned against master, where Kotlin had no date-time support. With this branch's date/time mapping: - Declare the "date-time" feature for KotlinXLanguage. date-time.schema itself stays skipped because its union-array properties hit the kotlinx sealed-class union limitation; the emitted KSerializers are exercised by JSON inputs with inferred date-times instead. - Skip the misc JSON inputs whose date-times carry trailing zeros in the fractional seconds, which java.time doesn't re-serialize identically — the same set already skipped for klaxon and jackson. - Skip date-time-or-string.schema: its string|date-time property was a plain string before and now becomes a union. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/languages.ts b/test/languages.ts index fc1687ba12..0deb4cc8ab 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1323,7 +1323,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: [ @@ -1398,6 +1402,16 @@ export const KotlinXLanguage: Language = { // the @SerialName annotations don't match the JSON keys. "blns-object.json", "identifiers.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + // (77392.json and b4865.json are already skipped above). + "54d32.json", + "0a358.json", + "80aff.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Unions render as sealed classes without serializer wiring, so @@ -1408,6 +1422,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 From 165d394fa70ed081e1a392374baa469863b074ed Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 14:02:49 -0400 Subject: [PATCH 10/13] fix(kotlin): only infer date-times that java.time round-trips faithfully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of skipping test inputs whose date-time strings java.time does not re-serialize byte-identically, give Kotlin a DateTimeRecognizer (the existing facility for this, mirroring SwiftDateTimeRecognizer) that refuses to infer date/time types from such strings in the first place. Rejected strings simply stay plain strings. The rules, all verified against java.time's actual parse/format behavior (ISO_LOCAL_DATE / ISO_OFFSET_TIME / ISO_OFFSET_DATE_TIME): - no trailing zeros in fractional seconds (java.time formats the shortest fraction: ".000" disappears, ".500" becomes ".5"), - at most 9 fractional digits (java.time's nanosecond precision; more don't parse), - offset "Z" or a nonzero "±hh:mm" within java.time's ±18:00 range (zero offsets format back as "Z", larger ones don't parse), - uppercase "T" and "Z" (lowercase forms format back in uppercase), - no Feb 29 outside leap years (java.time refuses to parse it). This only affects inference from JSON samples — JSON Schema's "format": "date-time" maps to OffsetDateTime regardless, which is the correct behavior for schema-driven input. The nine date-time-related misc-JSON skips on the Kotlin fixtures are lifted; every date/time-like string in the test inputs that the new recognizer accepts was verified to round-trip byte-identically through java.time (5692 strings checked). 77392.json and b4865.json stay skipped for kotlinx only, for the unrelated top-level-JsonArray reason. Co-Authored-By: Claude Fable 5 --- .../src/language/Kotlin/language.ts | 8 +++ .../src/language/Kotlin/utils.ts | 63 +++++++++++++++++ test/languages.ts | 32 --------- test/unit/date-time-recognizer.test.ts | 68 +++++++++++++++++++ 4 files changed, 139 insertions(+), 32 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index 4d4bbb17a0..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, @@ -20,6 +21,7 @@ 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), @@ -71,6 +73,12 @@ export class KotlinTargetLanguage extends TargetLanguage< 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 5810d61823..6c20ebd47c 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,4 @@ +import { DefaultDateTimeRecognizer } from "../../DateTime.js"; import type { Type } from "../../Type/index.js"; import { @@ -58,6 +59,68 @@ export function stringEscape(s: string): string { 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), diff --git a/test/languages.ts b/test/languages.ts index 0deb4cc8ab..94f9828e3a 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1156,17 +1156,6 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - "54d32.json", - "77392.json", - "0a358.json", - "80aff.json", - "b4865.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1256,17 +1245,6 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - "54d32.json", - "77392.json", - "0a358.json", - "80aff.json", - "b4865.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1402,16 +1380,6 @@ export const KotlinXLanguage: Language = { // the @SerialName annotations don't match the JSON keys. "blns-object.json", "identifiers.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - // (77392.json and b4865.json are already skipped above). - "54d32.json", - "0a358.json", - "80aff.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Unions render as sealed classes without serializer wiring, so 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); + }); +}); From 0043ecc507167892f16d3d031284f36b0e328758 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 18:01:20 -0400 Subject: [PATCH 11/13] fix(cjson): correct includes in generated source/header split Fixes for the header/source split feature (header-only=false): - startSourceFile now takes the header filename at every call site and derives the source filename itself. Previously the multi-source call sites passed the source filename, so every generated .c file began with an unguarded self-include (#include ) that made compilation recurse infinitely. - The redundant unconditional emitIncludeLine(headerFilename, true) after startSourceFile is gone. In header-only mode it made every generated header include itself (an unintended change to the pre-existing multi-source output); in split mode it duplicated the include emitted by startSourceFile. - The source file's include of its own header is now a quoted include, matching the existing convention: quoted includes for generated files, angle brackets only for system and vendored headers. - getSourceNameFromHeaderName anchors the extension swap (/\.h$/), so a header named my.house.h maps to my.house.c, not my.couse.h. - The header-only option is a BooleanOption instead of a string EnumOption, giving the usual --[no-]header-only CLI flags. - The five duplicated finishHeaderFile/finishSourceFile blocks are factored into finishCurrentFile, and a comment typo is fixed. Co-Authored-By: Claude Fable 5 --- .../src/language/CJSON/CJSONRenderer.ts | 126 ++++++++---------- .../src/language/CJSON/language.ts | 9 +- 2 files changed, 57 insertions(+), 78 deletions(-) diff --git a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts index 3216bfc04b..ec5559a92a 100644 --- a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts +++ b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts @@ -57,9 +57,13 @@ import { } from "./utils"; export class CJSONRenderer extends ConvenienceRenderer { - private currentHeaderFilename: string | undefined; /* Current header filename */ + private currentHeaderFilename: + | string + | undefined; /* Current header filename */ - private currentSourceFilename: string | undefined; /* Current source filename */ + private currentSourceFilename: + | string + | undefined; /* Current source filename */ private readonly memberNameStyle: NameStyle; /* Member name style */ @@ -343,13 +347,7 @@ export class CJSONRenderer extends ConvenienceRenderer { (type) => this.namedTypeToNameForTopLevel(type) === undefined, ); - if (this._options.headerOnly) { - /* Close header file */ - this.finishHeaderFile(); - } else { - /* Close source file */ - this.finishSourceFile(); - } + this.finishCurrentFile(); } /** @@ -387,8 +385,6 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create file */ const enumName = this.nameForNamedType(enumType); const headerFilename = this.sourcelikeToString(enumName).concat(".h"); - const sourceFilename = - this.getSourceNameFromHeaderName(headerFilename); this.includes.push(headerFilename); this.startHeaderFile(headerFilename); @@ -406,22 +402,13 @@ export class CJSONRenderer extends ConvenienceRenderer { this.finishHeaderFile(); /* Create source file */ - this.startSourceFile(sourceFilename); + this.startSourceFile(headerFilename); } - /* Include corresponding header file */ - this.emitIncludeLine(headerFilename, true); - /* Create functions */ this.emitEnumFunctions(enumType); - if (this._options.headerOnly) { - /* Close header file */ - this.finishHeaderFile(); - } else { - /* Close source file */ - this.finishSourceFile(); - } + this.finishCurrentFile(); } /** @@ -582,8 +569,6 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create file */ const unionName = this.nameForNamedType(unionType); const headerFilename = this.sourcelikeToString(unionName).concat(".h"); - const sourceFilename = - this.getSourceNameFromHeaderName(headerFilename); this.includes.push(headerFilename); this.startHeaderFile(headerFilename); @@ -601,22 +586,13 @@ export class CJSONRenderer extends ConvenienceRenderer { this.finishHeaderFile(); /* Create source file */ - this.startSourceFile(sourceFilename); + this.startSourceFile(headerFilename); } - /* Include corresponding header file */ - this.emitIncludeLine(headerFilename, true); - /* Create functions */ this.emitUnionFunctions(unionType); - if (this._options.headerOnly) { - /* Close header file */ - this.finishHeaderFile(); - } else { - /* Close source file */ - this.finishSourceFile(); - } + this.finishCurrentFile(); } /** @@ -2049,8 +2025,6 @@ export class CJSONRenderer extends ConvenienceRenderer { /* Create file */ const className = this.nameForNamedType(classType); const headerFilename = this.sourcelikeToString(className).concat(".h"); - const sourceFilename = - this.getSourceNameFromHeaderName(headerFilename); this.includes.push(headerFilename); this.startHeaderFile(headerFilename); @@ -2068,22 +2042,13 @@ export class CJSONRenderer extends ConvenienceRenderer { this.finishHeaderFile(); /* Create source file */ - this.startSourceFile(sourceFilename); + this.startSourceFile(headerFilename); } - /* Include corresponding header file */ - this.emitIncludeLine(headerFilename, true); - /* Create functions */ this.emitClassFunctions(classType); - if (this._options.headerOnly) { - /* Close header file */ - this.finishHeaderFile(); - } else { - /* Close source file */ - this.finishSourceFile(); - } + this.finishCurrentFile(); } /** @@ -4686,7 +4651,6 @@ export class CJSONRenderer extends ConvenienceRenderer { ): void { /* Create file */ const headerFilename = this.sourcelikeToString(className).concat(".h"); - const sourceFilename = this.getSourceNameFromHeaderName(headerFilename); this.startHeaderFile(headerFilename); /* Create includes - This create too much includes but this is safer because of specific corner cases */ @@ -4706,22 +4670,13 @@ export class CJSONRenderer extends ConvenienceRenderer { this.finishHeaderFile(); /* Create source file */ - this.startSourceFile(sourceFilename); + this.startSourceFile(headerFilename); } - /* Include corresponding header file */ - this.emitIncludeLine(headerFilename, true); - /* Create functions */ this.emitTopLevelFunctions(type, className); - if (this._options.headerOnly) { - /* Close header file */ - this.finishHeaderFile(); - } else { - /* Close source file */ - this.finishSourceFile(); - } + this.finishCurrentFile(); } /** @@ -5649,7 +5604,8 @@ export class CJSONRenderer extends ConvenienceRenderer { "Previous header file wasn't finished", ); if (proposedFilename !== undefined) { - this.currentHeaderFilename = this.sourcelikeToString(proposedFilename); + this.currentHeaderFilename = + this.sourcelikeToString(proposedFilename); } /* Check if header file has been created */ @@ -5722,30 +5678,37 @@ export class CJSONRenderer extends ConvenienceRenderer { /** * Function called to create a source file - * @param proposedFilename: source filename provided from stdin + * @param headerFilename: filename of the header file corresponding to this source file */ - protected startSourceFile(proposedFilename: Sourcelike): void { + 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 (proposedFilename !== undefined) { - this.currentSourceFilename = this.getSourceNameFromHeaderName(this.sourcelikeToString(proposedFilename)); + assert( + this.currentSourceFilename === undefined, + "Previous source file wasn't finished", + ); + if (headerFilename !== undefined) { + this.currentSourceFilename = this.getSourceNameFromHeaderName( + this.sourcelikeToString(headerFilename), + ); } - /* Check if source file and corresponding header file has been created */ + /* 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 file has been autogenerated using quicktype https://github.com/quicktype/quicktype - DO NOT EDIT", ]); this.ensureBlankLine(); - this.emitIncludeLine(this.sourcelikeToString(proposedFilename), true); + + /* Include corresponding header file */ + this.emitIncludeLine(this.sourcelikeToString(headerFilename)); this.ensureBlankLine(); } } /** - * Function called to close current file + * Function called to close current header file */ protected finishHeaderFile(): void { /* Check if header file has been created */ @@ -5769,7 +5732,7 @@ export class CJSONRenderer extends ConvenienceRenderer { ); this.ensureBlankLine(); - /* Close headeerfile */ + /* Close header file */ super.finishFile(defined(this.currentHeaderFilename)); this.currentHeaderFilename = undefined; } @@ -5789,6 +5752,20 @@ export class CJSONRenderer extends ConvenienceRenderer { } } + /** + * 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(); + } + } + /** * Check if type need declaration before use * @note If returning true, canBeForwardDeclared must be declared @@ -6056,7 +6033,12 @@ export class CJSONRenderer extends ConvenienceRenderer { 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"); + 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 a5355b69cb..a7bfde1bc4 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"; import { + BooleanOption, EnumOption, StringOption, getOptionValues, @@ -110,14 +111,10 @@ export const cJSONOptions = { namingStyles, "upper-underscore-case", ), - headerOnly: new EnumOption( + headerOnly: new BooleanOption( "header-only", "Generate headers only", - { - true: true, - false: false, - } as const, - "true", + true, "secondary", ), }; From 2d1c3dddf61741393f79dcec795532763ef75ba3 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 18:01:34 -0400 Subject: [PATCH 12/13] test(cjson): overhaul fixtures for the source/header split modes - The vendored dependencies (cJSON, hashtable, list) are downloaded into a deps/ subdirectory and included with -isystem instead of -I.. Angle-bracket includes now resolve only in deps/, while quoted includes resolve relative to the including file, so a generated #include fails to compile and the include-style convention is enforced by the build. - second.c is a second translation unit including TopLevel.h. It is compiled and linked in the split-mode compile commands, verifying that split output supports multi-TU builds (issue #2617). - Three minimal mode fixtures run one complex input (nbl-stats.json, which produces enums, unions and 22 type pairs) through the remaining option combinations: - cjson-default: single-source, header-only (the pre-existing default output mode) - cjson-multi-header: multi-source, header-only (single TU; header-only output cannot link from multiple TUs) - cjson-multi-split: multi-source with header/source pairs, compiled with *.c so every generated source file is linked - The cjson comment-injection target's output is TopLevel.h; with header-only=false a proposed filename of TopLevel.c would have named both generated files TopLevel.c. Co-Authored-By: Claude Fable 5 --- .github/workflows/test-pr.yaml | 1 + .gitignore | 4 +- test/fixtures.ts | 7 ++- test/fixtures/cjson/second.c | 8 +++ test/languages.ts | 100 +++++++++++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/cjson/second.c diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 6f67d78118..07af9b9bd3 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -24,6 +24,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/test/fixtures.ts b/test/fixtures.ts index 0472762b33..adea033712 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -947,7 +947,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, @@ -1550,6 +1552,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 07d52c3a1e..365d120912 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -490,16 +490,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 TopLevel.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 */ @@ -579,6 +589,84 @@ export const CJSONLanguage: Language = { 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: [], + 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"], +}; + export const CPlusPlusLanguage: Language = { name: "cplusplus", base: "test/fixtures/cplusplus", From 9726a94e7c2d9af686dde478c347517063e5fa8a Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 18:05:39 -0400 Subject: [PATCH 13/13] test(cjson): unit-test include structure of split output Renders a small input through the quicktype API and asserts, for the multi-source modes, that every header gets a source file, that no generated file includes itself, that generated files are referenced with quoted includes (never angle brackets), that each source file includes its own header first, and that header-only mode emits no source files. Also pins the output filenames for a proposed filename with an inner ".h" (my.house.h -> my.house.c). Co-Authored-By: Claude Fable 5 --- test/unit/cjson-split-sources.test.ts | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/unit/cjson-split-sources.test.ts 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", + ]); + }); +});