diff --git a/packages/quicktype-core/src/ConvenienceRenderer.ts b/packages/quicktype-core/src/ConvenienceRenderer.ts index af8ff1603..74280a0d9 100644 --- a/packages/quicktype-core/src/ConvenienceRenderer.ts +++ b/packages/quicktype-core/src/ConvenienceRenderer.ts @@ -847,6 +847,10 @@ export abstract class ConvenienceRenderer extends Renderer { return this.nameStoreView.get(t); } + protected hasNameForType(t: Type): boolean { + return this.nameStoreView.tryGet(t) !== undefined; + } + protected isForwardDeclaredType(t: Type): boolean { return defined(this._declarationIR).forwardedTypes.has(t); } diff --git a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts index 7f0c0b2e4..1f00bff61 100644 --- a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts +++ b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts @@ -19,12 +19,12 @@ import type { TargetLanguage } from "../../TargetLanguage.js"; import { ArrayType, type ClassProperty, - type ClassType, + ClassType, EnumType, MapType, type Type, type TypeKind, - type UnionType, + UnionType, } from "../../Type/index.js"; import { matchType, @@ -48,6 +48,8 @@ export class SwiftRenderer extends ConvenienceRenderer { private _needNull = false; + private readonly _nestedTypeParents = new Map(); + public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, @@ -193,18 +195,18 @@ export class SwiftRenderer extends ConvenienceRenderer { this.swiftType(arrayType.items, withIssues), "]", ], - (classType) => this.nameForNamedType(classType), + (classType) => this.swiftNameForNamedType(classType), (mapType) => [ "[String: ", this.swiftType(mapType.values, withIssues), "]", ], - (enumType) => this.nameForNamedType(enumType), + (enumType) => this.swiftNameForNamedType(enumType), (unionType) => { const nullable = nullableFromUnion(unionType); if (nullable !== null) return [this.swiftType(nullable, withIssues), optional]; - return this.nameForNamedType(unionType); + return this.swiftNameForNamedType(unionType); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -450,6 +452,78 @@ export class SwiftRenderer extends ConvenienceRenderer { return ""; } + private swiftNameForNamedType(t: Type): Sourcelike { + const parent = this._nestedTypeParents.get(t); + if (parent === undefined) return this.nameForNamedType(t); + return [this.nameForNamedType(parent), ".", this.nameForNamedType(t)]; + } + + // Returns the top-level type that owns `c` (itself when `c` is a top-level + // type, or its nested-type parent otherwise). Used to qualify JSON helper + // calls so each generated file is self-contained and multi-source outputs + // don't collide on module-level `newJSONDecoder`/`newJSONEncoder`. + private swiftHelperOwner(c: ClassType): ClassType { + return this._nestedTypeParents.get(c) ?? c; + } + + private jsonDecoderCall(c: ClassType): Sourcelike { + if (this._options.nestTypes) { + return [ + this.nameForNamedType(this.swiftHelperOwner(c)), + ".newJSONDecoder()", + ]; + } + return "newJSONDecoder()"; + } + + private jsonEncoderCall(c: ClassType): Sourcelike { + if (this._options.nestTypes) { + return [ + this.nameForNamedType(this.swiftHelperOwner(c)), + ".newJSONEncoder()", + ]; + } + return "newJSONEncoder()"; + } + + private hasNonNamedTopLevels(): boolean { + for (const t of this.topLevels.values()) { + if (this.namedTypeToNameForTopLevel(t) === undefined) return true; + } + return false; + } + + private setUpNestedTypes(): void { + if (!this._options.nestTypes) return; + + const topLevels = new Set(this.topLevels.values()); + const visited = new Set(); + const visit = (t: Type, owner: ClassType): void => { + if (visited.has(t)) return; + visited.add(t); + for (const child of t.getChildren()) { + if (topLevels.has(child)) continue; + if ( + this.hasNameForType(child) && + !this._nestedTypeParents.has(child) + ) { + this._nestedTypeParents.set(child, owner); + } + visit(child, owner); + } + }; + + for (const t of topLevels) { + if (t instanceof ClassType) visit(t, t); + } + } + + private nestedTypesFor(owner: ClassType): Type[] { + return Array.from(this._nestedTypeParents.entries()) + .filter(([, parent]) => parent === owner) + .map(([type]) => type); + } + /// startFile takes a file name, appends ".swift" to it and sets it as the current filename. protected startFile(basename: Sourcelike): void { if (this._options.multiFileOutput === false) { @@ -489,13 +563,17 @@ export class SwiftRenderer extends ConvenienceRenderer { ]; } - private renderClassDefinition(c: ClassType, className: Name): void { - this.startFile(className); + private renderClassDefinition( + c: ClassType, + className: Name, + nested = false, + ): void { + if (!nested) this.startFile(className); - this.renderHeader(c, className); + if (!nested) this.renderHeader(c, className); this.emitDescription(this.descriptionForType(c)); - this.emitMark(this.sourcelikeToString(className), true); + if (!nested) this.emitMark(this.sourcelikeToString(className), true); const isClass = this._options.useClasses || this.isCycleBreakerType(c); const structOrClass = isClass ? "class" : "struct"; @@ -687,11 +765,34 @@ export class SwiftRenderer extends ConvenienceRenderer { ); } } + + if (this._options.nestTypes) { + for (const nestedType of this.nestedTypesFor(c)) { + this.ensureBlankLine(); + this.renderNestedType(nestedType); + } + } + + if ( + !nested && + this._options.nestTypes && + !this._options.justTypes && + this._options.convenienceInitializers + ) { + this.ensureBlankLine(); + this.emitMark( + "Helper functions for creating encoders and decoders", + ); + this.emitNewEncoderDecoder("static "); + } }, ); - // FIXME: We emit only the MARK line for top-level-enum.schema - if (!this._options.justTypes && this._options.convenienceInitializers) { + if ( + !nested && + !this._options.justTypes && + this._options.convenienceInitializers + ) { this.ensureBlankLine(); this.emitMark( this.sourcelikeToString(className) + @@ -699,10 +800,19 @@ export class SwiftRenderer extends ConvenienceRenderer { ); this.ensureBlankLine(); this.emitConvenienceInitializersExtension(c, className); + for (const nestedType of this.nestedTypesFor(c)) { + if (nestedType instanceof ClassType) { + this.ensureBlankLine(); + this.emitConvenienceInitializersExtension( + nestedType, + this.swiftNameForNamedType(nestedType), + ); + } + } this.ensureBlankLine(); } - this.endFile(); + if (!nested) this.endFile(); } protected initializableProperties(c: ClassType): SwiftProperty[] { @@ -718,8 +828,8 @@ export class SwiftRenderer extends ConvenienceRenderer { return properties; } - private emitNewEncoderDecoder(): void { - this.emitBlock("func newJSONDecoder() -> JSONDecoder", () => { + private emitNewEncoderDecoder(prefix = ""): void { + this.emitBlock([prefix, "func newJSONDecoder() -> JSONDecoder"], () => { this.emitLine("let decoder = JSONDecoder()"); if (!this._options.linux) { this.emitBlock( @@ -754,7 +864,7 @@ export class SwiftRenderer extends ConvenienceRenderer { this.emitLine("return decoder"); }); this.ensureBlankLine(); - this.emitBlock("func newJSONEncoder() -> JSONEncoder", () => { + this.emitBlock([prefix, "func newJSONEncoder() -> JSONEncoder"], () => { this.emitLine("let encoder = JSONEncoder()"); if (!this._options.linux) { this.emitBlock( @@ -780,7 +890,7 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); private emitConvenienceInitializersExtension( c: ClassType, - className: Name, + className: Sourcelike, ): void { const isClass = this._options.useClasses || this.isCycleBreakerType(c); const convenience = isClass ? "convenience " : ""; @@ -790,13 +900,17 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); this.emitBlock("convenience init(data: Data) throws", () => { if (this.propertyCount(c) > 0) { this.emitLine( - "let me = try newJSONDecoder().decode(", + "let me = try ", + this.jsonDecoderCall(c), + ".decode(", this.swiftType(c), ".self, from: data)", ); } else { this.emitLine( - "let _ = try newJSONDecoder().decode(", + "let _ = try ", + this.jsonDecoderCall(c), + ".decode(", this.swiftType(c), ".self, from: data)", ); @@ -812,7 +926,9 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); } else { this.emitBlock("init(data: Data) throws", () => { this.emitLine( - "self = try newJSONDecoder().decode(", + "self = try ", + this.jsonDecoderCall(c), + ".decode(", this.swiftType(c), ".self, from: data)", ); @@ -853,7 +969,11 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); // Convenience serializers this.ensureBlankLine(); this.emitBlock("func jsonData() throws -> Data", () => { - this.emitLine("return try newJSONEncoder().encode(self)"); + this.emitLine( + "return try ", + this.jsonEncoderCall(c), + ".encode(self)", + ); }); this.ensureBlankLine(); this.emitBlock( @@ -867,11 +987,17 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); }); } - private renderEnumDefinition(e: EnumType, enumName: Name): void { - this.startFile(enumName); + private renderEnumDefinition( + e: EnumType, + enumName: Name, + nested = false, + ): void { + if (!nested) this.startFile(enumName); - this.emitLineOnce("import Foundation"); - this.ensureBlankLine(); + if (!nested) { + this.emitLineOnce("import Foundation"); + this.ensureBlankLine(); + } this.emitDescription(this.descriptionForType(e)); const protocolString = this.getProtocolString("enum", "String"); @@ -902,14 +1028,20 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); ); } - this.endFile(); + if (!nested) this.endFile(); } - private renderUnionDefinition(u: UnionType, unionName: Name): void { - this.startFile(unionName); + private renderUnionDefinition( + u: UnionType, + unionName: Name, + nested = false, + ): void { + if (!nested) this.startFile(unionName); - this.emitLineOnce("import Foundation"); - this.ensureBlankLine(); + if (!nested) { + this.emitLineOnce("import Foundation"); + this.ensureBlankLine(); + } function sortBy(t: Type): string { const kind = t.kind; @@ -1042,7 +1174,18 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); } }, ); - this.endFile(); + if (!nested) this.endFile(); + } + + private renderNestedType(t: Type): void { + const name = this.nameForNamedType(t); + if (t instanceof ClassType) { + this.renderClassDefinition(t, name, true); + } else if (t instanceof EnumType) { + this.renderEnumDefinition(t, name, true); + } else if (t instanceof UnionType) { + this.renderUnionDefinition(t, name, true); + } } private emitTopLevelMapAndArrayConvenienceInitializerExtensions( @@ -1138,10 +1281,21 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); ); } + // With --nest-types the JSON helpers are emitted as `static func` + // members of each top-level class, so module-level helpers are only + // needed for array/map top-levels (which can't host statics) or for + // Alamofire. When they are needed alongside nesting, scope them to + // `fileprivate` to keep multi-source standalone output collision-free. + const needFreeHelpers = + !this._options.nestTypes || + this.hasNonNamedTopLevels() || + this._options.alamofire; + if ( - (!this._options.justTypes && + ((!this._options.justTypes && this._options.convenienceInitializers) || - this._options.alamofire + this._options.alamofire) && + needFreeHelpers ) { this.ensureBlankLine(); this.emitMark( @@ -1149,7 +1303,9 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); true, ); this.ensureBlankLine(); - this.emitNewEncoderDecoder(); + this.emitNewEncoderDecoder( + this._options.nestTypes ? "fileprivate " : "", + ); } if (this._options.alamofire) { @@ -1451,7 +1607,7 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); this.endFile(); }; - private emitConvenienceMutator(c: ClassType, className: Name): void { + private emitConvenienceMutator(c: ClassType, className: Sourcelike): void { this.emitLine("func with("); this.indent(() => { this.forEachClassProperty(c, "none", (name, _, p, position) => { @@ -1493,18 +1649,28 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); } protected emitSourceStructure(): void { + this.setUpNestedTypes(); if (this._options.multiFileOutput === false) { this.renderSingleFileHeaderComments(); } this.forEachNamedType( "leading-and-interposing", - (c: ClassType, className: Name) => - this.renderClassDefinition(c, className), - (e: EnumType, enumName: Name) => - this.renderEnumDefinition(e, enumName), - (u: UnionType, unionName: Name) => - this.renderUnionDefinition(u, unionName), + (c: ClassType, className: Name) => { + if (!this._nestedTypeParents.has(c)) { + this.renderClassDefinition(c, className); + } + }, + (e: EnumType, enumName: Name) => { + if (!this._nestedTypeParents.has(e)) { + this.renderEnumDefinition(e, enumName); + } + }, + (u: UnionType, unionName: Name) => { + if (!this._nestedTypeParents.has(u)) { + this.renderUnionDefinition(u, unionName); + } + }, ); if (!this._options.justTypes) { diff --git a/packages/quicktype-core/src/language/Swift/constants.ts b/packages/quicktype-core/src/language/Swift/constants.ts index 2298d1bd6..1e9d224ad 100644 --- a/packages/quicktype-core/src/language/Swift/constants.ts +++ b/packages/quicktype-core/src/language/Swift/constants.ts @@ -98,4 +98,5 @@ export const keywords = [ "convertDouble", "jsonString", "jsonData", + "CodingKeys", ] as const; diff --git a/packages/quicktype-core/src/language/Swift/language.ts b/packages/quicktype-core/src/language/Swift/language.ts index 7e9fecf15..db86338c8 100644 --- a/packages/quicktype-core/src/language/Swift/language.ts +++ b/packages/quicktype-core/src/language/Swift/language.ts @@ -19,6 +19,11 @@ import { SwiftRenderer } from "./SwiftRenderer.js"; import { SwiftDateTimeRecognizer } from "./utils.js"; export const swiftOptions = { + nestTypes: new BooleanOption( + "nest-types", + "Nest child types inside their owning top-level type", + false, + ), justTypes: new BooleanOption("just-types", "Plain types only", false), convenienceInitializers: new BooleanOption( "initializers", diff --git a/test/inputs/schema/coding-keys.1.json b/test/inputs/schema/coding-keys.1.json new file mode 100644 index 000000000..db75b812b --- /dev/null +++ b/test/inputs/schema/coding-keys.1.json @@ -0,0 +1,6 @@ +{ + "name": "example", + "coding-keys": { + "foo": "bar" + } +} diff --git a/test/inputs/schema/coding-keys.schema b/test/inputs/schema/coding-keys.schema new file mode 100644 index 000000000..852899db8 --- /dev/null +++ b/test/inputs/schema/coding-keys.schema @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "coding-keys": { + "type": "object", + "properties": { "foo": { "type": "string" } }, + "required": ["foo"] + } + }, + "required": ["name", "coding-keys"] +} diff --git a/test/languages.ts b/test/languages.ts index 5e721fad1..2a3dceae2 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1028,6 +1028,13 @@ export const SwiftLanguage: Language = { { "access-level": "public" }, { protocol: "equatable" }, ["simple-object.json", { protocol: "hashable" }], + // Compile + round-trip coverage for the nested-types output. + { "nest-types": "true" }, + // Exercise a child type that would otherwise be named `CodingKeys` + // and collide with the owner's synthesized `enum CodingKeys`. The + // child is renamed (e.g. to `CodingKeysClass`) and nested; this + // fixture compiles and round-trips it under --nest-types. + ["coding-keys.schema", { "nest-types": "true" }], ], sourceFiles: ["src/language/Swift/index.ts"], }; diff --git a/test/unit/swift-final-classes.test.ts b/test/unit/swift-final-classes.test.ts index 6dd680356..0cd773f58 100644 --- a/test/unit/swift-final-classes.test.ts +++ b/test/unit/swift-final-classes.test.ts @@ -110,4 +110,89 @@ describe("Swift class generation", () => { true, ); }); + + test("can nest child types to avoid multi-source name collisions", async () => { + const first = new JSONSchemaInput(undefined); + await first.addSource({ + name: "First", + schema: JSON.stringify(modelSchema), + }); + const second = new JSONSchemaInput(undefined); + await second.addSource({ + name: "Second", + schema: JSON.stringify(modelSchema), + }); + const inputData = new InputData(); + inputData.addInput(first); + inputData.addInput(second); + + const result = await quicktype({ + inputData, + lang: "swift", + rendererOptions: { "nest-types": true }, + }); + const output = result.lines.join("\n"); + + expect(output).toContain("struct First"); + expect(output).toContain("struct Second"); + expect(output).toContain("struct FirstChild"); + expect(output).toContain("struct SecondChild"); + expect(output).toContain("First.FirstChild"); + expect(output).toContain("Second.SecondChild"); + expect(output).not.toMatch(/^struct (?:FirstChild|SecondChild)/m); + }); + + test("scopes JSON helpers as static members when nesting", async () => { + const output = await renderSwift(modelSchema, { + "nest-types": "true", + }); + + // Helpers become static members of the top-level type ... + expect(output).toMatch( + /^ {4}static func newJSONDecoder\(\) -> JSONDecoder/m, + ); + expect(output).toMatch( + /^ {4}static func newJSONEncoder\(\) -> JSONEncoder/m, + ); + // ... and no module-level helper free functions are emitted. + expect(output).not.toMatch(/^func newJSONDecoder\(\)/m); + expect(output).not.toMatch(/^func newJSONEncoder\(\)/m); + // Convenience initializers qualify their helper calls with the owner. + expect(output).toContain("TopLevel.newJSONDecoder()"); + expect(output).toContain("TopLevel.newJSONEncoder()"); + }); + + test("keeps module-level helpers when nesting is disabled", async () => { + const output = await renderSwift(modelSchema); + + expect(output).toMatch(/^func newJSONDecoder\(\) -> JSONDecoder/m); + expect(output).toMatch(/^func newJSONEncoder\(\) -> JSONEncoder/m); + expect(output).not.toMatch(/static func newJSONDecoder/); + }); + + test("renames a child type named CodingKeys instead of shadowing the owner's synthesized enum", async () => { + const schema = { + type: "object", + properties: { + "coding-keys": { + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }, + }, + required: ["coding-keys"], + }; + + const output = await renderSwift(schema, { "nest-types": "true" }); + + // The owner emits a synthetic `enum CodingKeys`, so a child that + // would otherwise be named `CodingKeys` is renamed (e.g. to + // `CodingKeysClass`) and nested. Keeping the name `CodingKeys` + // would either redeclare the owner's enum (when nested) or shadow + // it during unqualified lookup (when left at module scope), both + // of which break `Codable` synthesis. + expect(output).not.toMatch(/\bstruct CodingKeys\b/); + expect(output).toMatch(/^ {4}struct CodingKeysClass: Codable/m); + expect(output).toMatch(/let codingKeys: \w*\.CodingKeysClass/); + }); });