diff --git a/src/quicktype-core/ConvenienceRenderer.ts b/src/quicktype-core/ConvenienceRenderer.ts index c6c0eb9a2d..b9fa8d4f92 100644 --- a/src/quicktype-core/ConvenienceRenderer.ts +++ b/src/quicktype-core/ConvenienceRenderer.ts @@ -644,6 +644,12 @@ export abstract class ConvenienceRenderer extends Renderer { this._alphabetizeProperties = value; } + // Returns the number of properties defined for the specified object type. + protected propertyCount(o: ObjectType): number { + const propertyNames = defined(this._propertyNamesStoreView).get(o); + return propertyNames.size; + } + protected forEachClassProperty( o: ObjectType, blankLocations: BlankLineConfig, @@ -898,7 +904,7 @@ export abstract class ConvenienceRenderer extends Renderer { processed.add(process(t)); } - for (; ;) { + for (;;) { const maybeType = queue.pop(); if (maybeType === undefined) { break; diff --git a/src/quicktype-core/Renderer.ts b/src/quicktype-core/Renderer.ts index 268174fb3d..73f2a4a1d3 100644 --- a/src/quicktype-core/Renderer.ts +++ b/src/quicktype-core/Renderer.ts @@ -95,6 +95,11 @@ class EmitContext { this.pushItem(item); } + containsItem(item: Sourcelike): boolean { + const existingItem = this._currentEmitTarget.find((value: Sourcelike) => item === value); + return existingItem !== undefined; + } + ensureBlankLine(numBlankLines: number): void { if (this._preventBlankLine) return; this._numBlankLinesNeeded = Math.max(this._numBlankLinesNeeded, numBlankLines); @@ -119,6 +124,7 @@ export abstract class Renderer { private _names: ReadonlyMap | undefined; private _finishedFiles: Map; + private _finishedEmitContexts: Map; private _emitContext: EmitContext; @@ -127,6 +133,7 @@ export abstract class Renderer { this.leadingComments = renderContext.leadingComments; this._finishedFiles = new Map(); + this._finishedEmitContexts = new Map(); this._emitContext = new EmitContext(); } @@ -142,6 +149,23 @@ export abstract class Renderer { this._emitContext.emitItem(item); } + emitItemOnce(item: Sourcelike): void { + if (this._emitContext.containsItem(item)) { + return; + } + + this.emitItem(item); + } + + emitLineOnce(...lineParts: Sourcelike[]): void { + if (lineParts.length === 1) { + this.emitItemOnce(lineParts[0]); + } else if (lineParts.length > 1) { + this.emitItemOnce(lineParts); + } + this._emitContext.emitNewline(); + } + emitLine(...lineParts: Sourcelike[]): void { if (lineParts.length === 1) { this._emitContext.emitItem(lineParts[0]); @@ -266,10 +290,22 @@ export abstract class Renderer { return assignNames(this.setUpNaming()); } + protected initializeEmitContextForFilename(filename: string): void { + if (this._finishedEmitContexts.has(filename.toLowerCase())) { + const existingEmitContext = this._finishedEmitContexts.get(filename.toLowerCase()); + if (existingEmitContext !== undefined) { + this._emitContext = existingEmitContext; + } + } + } + protected finishFile(filename: string): void { assert(!this._finishedFiles.has(filename), `Tried to emit file ${filename} more than once`); const source = sourcelikeToSource(this._emitContext.source); this._finishedFiles.set(filename, source); + + // [Michael Fey (@MrRooni), 2019-5-9] We save the current EmitContext for possible reuse later. We put it into the map with a lowercased version of the key so we can do a case-insensitive lookup later. The reason we lowercase it is because some schema (looking at you keyword-unions.schema) define objects of the same name with different casing. BOOL vs. bool, for example. + this._finishedEmitContexts.set(filename.toLowerCase(), this._emitContext); this._emitContext = new EmitContext(); } diff --git a/src/quicktype-core/language/Swift.ts b/src/quicktype-core/language/Swift.ts index d5814c4a80..878a116686 100644 --- a/src/quicktype-core/language/Swift.ts +++ b/src/quicktype-core/language/Swift.ts @@ -1,4 +1,5 @@ import { arrayIntercalate } from "collection-utils"; +import { assert, defined } from "../support/Support"; import { TargetLanguage } from "../TargetLanguage"; import { @@ -60,6 +61,12 @@ export const swiftOptions = { "Objects inherit from NSObject and @objcMembers is added to classes", false ), + swift5Support: new BooleanOption("swift-5-support", "Renders output in a Swift 5 compatible mode", false), + multiFileOutput: new BooleanOption( + "multi-file-output", + "Renders each top-level object in its own Swift file", + false + ), accessLevel: new EnumOption( "access-level", "Access level", @@ -125,7 +132,9 @@ export class SwiftTargetLanguage extends TargetLanguage { swiftOptions.namedTypePrefix, swiftOptions.protocol, swiftOptions.acronymStyle, - swiftOptions.objcSupport + swiftOptions.objcSupport, + swiftOptions.swift5Support, + swiftOptions.multiFileOutput ]; } @@ -180,6 +189,7 @@ const keywords = [ "continue", "default", "defer", + "description", "do", "else", "fallthrough", @@ -290,6 +300,7 @@ function unicodeEscape(codePoint: number): string { const stringEscape = utf32ConcatMap(escapeNonPrintableMapper(isPrintable, unicodeEscape)); export class SwiftRenderer extends ConvenienceRenderer { + private _currentFilename: string | undefined; private _needAny: boolean = false; private _needNull: boolean = false; @@ -427,14 +438,43 @@ export class SwiftRenderer extends ConvenienceRenderer { return null; } - private renderHeader(): void { + private renderSingleFileHeaderComments(): void { + this.emitLineOnce("// This file was generated from JSON Schema using quicktype, do not modify it directly."); + this.emitLineOnce("// To parse the JSON, add this file to your project and do:"); + this.emitLineOnce("//"); + this.forEachTopLevel("none", (t, topLevelName) => { + if (this._options.convenienceInitializers && !(t instanceof EnumType)) { + this.emitLineOnce( + "// let ", + modifySource(camelCase, topLevelName), + " = try ", + topLevelName, + "(json)" + ); + } else { + this.emitLineOnce( + "// let ", + modifySource(camelCase, topLevelName), + " = ", + "try? newJSONDecoder().decode(", + topLevelName, + ".self, from: jsonData)" + ); + } + }); + } + + private renderHeader(type: Type, name: Name): void { if (this.leadingComments !== undefined) { this.emitCommentLines(this.leadingComments); } else if (!this._options.justTypes) { - this.emitLine("// To parse the JSON, add this file to your project and do:"); - this.emitLine("//"); - this.forEachTopLevel("none", (t, name) => { - if (this._options.convenienceInitializers && !(t instanceof EnumType)) { + if (this._options.multiFileOutput) { + this.emitLineOnce( + "// This file was generated from JSON Schema using quicktype, do not modify it directly." + ); + this.emitLineOnce("// To parse the JSON, add this file to your project and do:"); + this.emitLineOnce("//"); + if (this._options.convenienceInitializers && !(type instanceof EnumType)) { this.emitLine("// let ", modifySource(camelCase, name), " = try ", name, "(json)"); } else { this.emitLine( @@ -446,40 +486,36 @@ export class SwiftRenderer extends ConvenienceRenderer { ".self, from: jsonData)" ); } - }); + } if (this._options.urlSession) { this.emitLine("//"); this.emitLine("// To read values from URLs:"); - this.forEachTopLevel("none", (_, name) => { - const lowerName = modifySource(camelCase, name); - this.emitLine("//"); - this.emitLine( - "// let task = URLSession.shared.", - lowerName, - "Task(with: url) { ", - lowerName, - ", response, error in" - ); - this.emitLine("// if let ", lowerName, " = ", lowerName, " {"); - this.emitLine("// ..."); - this.emitLine("// }"); - this.emitLine("// }"); - this.emitLine("// task.resume()"); - }); + const lowerName = modifySource(camelCase, name); + this.emitLine("//"); + this.emitLine( + "// let task = URLSession.shared.", + lowerName, + "Task(with: url) { ", + lowerName, + ", response, error in" + ); + this.emitLine("// if let ", lowerName, " = ", lowerName, " {"); + this.emitLine("// ..."); + this.emitLine("// }"); + this.emitLine("// }"); + this.emitLine("// task.resume()"); } if (this._options.alamofire) { this.emitLine("//"); this.emitLine("// To parse values from Alamofire responses:"); - this.forEachTopLevel("none", (_, name) => { - this.emitLine("//"); - this.emitLine("// Alamofire.request(url).response", name, " { response in"); - this.emitLine("// if let ", modifySource(camelCase, name), " = response.result.value {"); - this.emitLine("// ..."); - this.emitLine("// }"); - this.emitLine("// }"); - }); + this.emitLine("//"); + this.emitLine("// Alamofire.request(url).response", name, " { response in"); + this.emitLine("// if let ", modifySource(camelCase, name), " = response.result.value {"); + this.emitLine("// ..."); + this.emitLine("// }"); + this.emitLine("// }"); } if (this._options.protocol.hashable || this._options.protocol.equatable) { @@ -495,14 +531,15 @@ export class SwiftRenderer extends ConvenienceRenderer { } } this.ensureBlankLine(); - this.emitLine("import Foundation"); + this.emitLineOnce("import Foundation"); if (!this._options.justTypes && this._options.alamofire) { - this.emitLine("import Alamofire"); + this.emitLineOnce("import Alamofire"); } + this.ensureBlankLine(); } private renderTopLevelAlias(t: Type, name: Name): void { - this.emitLine("typealias ", name, " = ", this.swiftType(t, true)); + this.emitLine(this.accessLevel, "typealias ", name, " = ", this.swiftType(t, true)); } protected getProtocolsArray(_t: Type, isClass: boolean): string[] { @@ -567,19 +604,54 @@ export class SwiftRenderer extends ConvenienceRenderer { : this._options.accessLevel + " "; } + private get objcMembersDeclaration(): string { + if (this._options.objcSupport) { + return "@objcMembers "; + } + + return ""; + } + + /// 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) { + return; + } + + assert(this._currentFilename === undefined, "Previous file wasn't finished: " + this._currentFilename); + // FIXME: The filenames should actually be Sourcelikes, too + this._currentFilename = `${this.sourcelikeToString(basename)}.swift`; + this.initializeEmitContextForFilename(this._currentFilename); + } + + /// endFile pushes the current file name onto the collection of finished files and then resets the current file name. These finished files are used in index.ts to write the output. + protected endFile(): void { + if (this._options.multiFileOutput === false) { + return; + } + + this.finishFile(defined(this._currentFilename)); + this._currentFilename = undefined; + } + protected propertyLinesDefinition(name: Name, parameter: ClassProperty): Sourcelike { return [this.accessLevel, "let ", name, ": ", this.swiftPropertyType(parameter)]; } private renderClassDefinition(c: ClassType, className: Name): void { + this.startFile(className); + + this.renderHeader(c, className); this.emitDescription(this.descriptionForType(c)); + this.emitMark(this.sourcelikeToString(className), true); + const isClass = this._options.useClasses || this.isCycleBreakerType(c); const structOrClass = isClass ? "class" : "struct"; if (isClass && this._options.objcSupport) { // [Michael Fey (@MrRooni), 2019-4-24] Swift 5 or greater, must come before the access declaration for the class. - this.emitItem("@objcMembers "); + this.emitItem(this.objcMembersDeclaration); } this.emitBlockWithAccess([structOrClass, " ", className, this.getProtocolString(c, isClass)], () => { @@ -668,13 +740,32 @@ export class SwiftRenderer extends ConvenienceRenderer { if (propertiesLines.length > 0) propertiesLines.push(", "); propertiesLines.push(property.name, ": ", this.swiftPropertyType(property.parameter)); } - this.emitBlockWithAccess(["init(", ...propertiesLines, ")"], () => { - for (let property of initProperties) { - this.emitLine("self.", property.name, " = ", property.name); - } - }); + if (this.propertyCount(c) === 0 && this._options.objcSupport) { + this.emitBlockWithAccess(["override init()"], () => { + ""; + }); + } else { + this.emitBlockWithAccess(["init(", ...propertiesLines, ")"], () => { + for (let property of initProperties) { + this.emitLine("self.", property.name, " = ", property.name); + } + }); + } } }); + + if (!this._options.justTypes) { + // FIXME: We emit only the MARK line for top-level-enum.schema + if (this._options.convenienceInitializers) { + this.ensureBlankLine(); + this.emitMark(this.sourcelikeToString(className) + " convenience initializers and mutators"); + this.ensureBlankLine(); + this.emitConvenienceInitializersExtension(c, className); + this.ensureBlankLine(); + } + } + + this.endFile(); } protected initializableProperties(c: ClassType): SwiftProperty[] { @@ -687,7 +778,7 @@ export class SwiftRenderer extends ConvenienceRenderer { } private emitNewEncoderDecoder(): void { - this.emitBlock("fileprivate func newJSONDecoder() -> JSONDecoder", () => { + this.emitBlock("func newJSONDecoder() -> JSONDecoder", () => { this.emitLine("let decoder = JSONDecoder()"); if (!this._options.linux) { this.emitBlock("if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *)", () => { @@ -716,7 +807,7 @@ export class SwiftRenderer extends ConvenienceRenderer { this.emitLine("return decoder"); }); this.ensureBlankLine(); - this.emitBlock("fileprivate func newJSONEncoder() -> JSONEncoder", () => { + this.emitBlock("func newJSONEncoder() -> JSONEncoder", () => { this.emitLine("let encoder = JSONEncoder()"); if (!this._options.linux) { this.emitBlock("if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *)", () => { @@ -741,7 +832,11 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); this.emitBlockWithAccess(["extension ", className], () => { if (isClass) { this.emitBlock("convenience init(data: Data) throws", () => { - this.emitLine("let me = try newJSONDecoder().decode(", this.swiftType(c), ".self, from: data)"); + if (this.propertyCount(c) > 0) { + this.emitLine("let me = try newJSONDecoder().decode(", this.swiftType(c), ".self, from: data)"); + } else { + this.emitLine("let _ = try newJSONDecoder().decode(", this.swiftType(c), ".self, from: data)"); + } let args: Sourcelike[] = []; this.forEachClassProperty(c, "none", name => { if (args.length > 0) args.push(", "); @@ -785,6 +880,11 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); } private renderEnumDefinition(e: EnumType, enumName: Name): void { + this.startFile(enumName); + + this.emitLineOnce("import Foundation"); + this.ensureBlankLine(); + this.emitDescription(this.descriptionForType(e)); const protocols: string[] = []; @@ -816,9 +916,16 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); }); }); } + + this.endFile(); } private renderUnionDefinition(u: UnionType, unionName: Name): void { + this.startFile(unionName); + + this.emitLineOnce("import Foundation"); + this.ensureBlankLine(); + function sortBy(t: Type): string { const kind = t.kind; if (kind === "class") return kind; @@ -880,6 +987,7 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); }); } }); + this.endFile(); } private emitTopLevelMapAndArrayConvenienceInitializerExtensions(t: Type, name: Name): void { @@ -930,22 +1038,85 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); } private emitSupportFunctions4 = (): void => { + this.startFile("JSONSchemaSupport"); + + this.emitLineOnce("import Foundation"); + + this.forEachTopLevel( + "leading", + (t: Type, name: Name) => this.renderTopLevelAlias(t, name), + t => this.namedTypeToNameForTopLevel(t) === undefined + ); + + if (this._options.convenienceInitializers) { + this.ensureBlankLine(); + this.forEachTopLevel("leading-and-interposing", (t: Type, name: Name) => + this.emitTopLevelMapAndArrayConvenienceInitializerExtensions(t, name) + ); + } + + if ( + (!this._options.justTypes && this._options.convenienceInitializers) || + this._options.urlSession || + this._options.alamofire + ) { + this.ensureBlankLine(); + this.emitMark("Helper functions for creating encoders and decoders", true); + this.ensureBlankLine(); + this.emitNewEncoderDecoder(); + } + + if (this._options.urlSession) { + this.ensureBlankLine(); + this.emitMark("URLSession response handlers", true); + this.ensureBlankLine(); + this.emitURLSessionExtension(); + } + + if (this._options.alamofire) { + this.ensureBlankLine(); + this.emitMark("Alamofire response handlers", true); + this.ensureBlankLine(); + this.emitAlamofireExtension(); + } + // This assumes that this method is called after declarations // are emitted. if (this._needAny || this._needNull) { - this.emitMark("Encode/decode helpers"); this.ensureBlankLine(); - this.emitMultiline(`${this.accessLevel}class JSONNull: Codable, Hashable { - - public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { + this.emitMark("Encode/decode helpers", true); + this.ensureBlankLine(); + if (this._options.objcSupport) { + this.emitLine(this.objcMembersDeclaration, this.accessLevel, "class JSONNull: NSObject, Codable {"); + } else { + this.emitLine(this.accessLevel, "class JSONNull: Codable, Hashable {"); + } + this.ensureBlankLine(); + this.emitMultiline(` public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { return true - } - - public var hashValue: Int { + }`); + + if (this._options.objcSupport === false) { + this.ensureBlankLine(); + this.emitMultiline(` public var hashValue: Int { return 0 - } + }`); + + if (this._options.swift5Support) { + this.ensureBlankLine(); + this.emitMultiline(` public func hash(into hasher: inout Hasher) { + // No-op + }`); + } + } - public init() {} + this.ensureBlankLine(); + if (this._options.objcSupport) { + this.emitItem(" override "); + } else { + this.emitItem(" "); + } + this.emitMultiline(`public init() {} public required init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -980,10 +1151,16 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); var stringValue: String { return key } -} +}`); -${this.accessLevel}class JSONAny: Codable { - ${this.accessLevel}let value: Any + this.ensureBlankLine(); + if (this._options.objcSupport) { + this.emitLine(this.objcMembersDeclaration, this.accessLevel, "class JSONAny: NSObject, Codable {"); + } else { + this.emitLine(this.accessLevel, "class JSONAny: Codable {"); + } + this.ensureBlankLine(); + this.emitMultiline(` ${this.accessLevel}let value: Any static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError { let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny") @@ -1176,6 +1353,8 @@ ${this.accessLevel}class JSONAny: Codable { } }`); } + + this.endFile(); }; private emitConvenienceMutator(c: ClassType, className: Name) { @@ -1214,13 +1393,9 @@ ${this.accessLevel}class JSONAny: Codable { } protected emitSourceStructure(): void { - this.renderHeader(); - - this.forEachTopLevel( - "leading", - (t: Type, name: Name) => this.renderTopLevelAlias(t, name), - t => this.namedTypeToNameForTopLevel(t) === undefined - ); + if (this._options.multiFileOutput === false) { + this.renderSingleFileHeaderComments(); + } this.forEachNamedType( "leading-and-interposing", @@ -1230,48 +1405,8 @@ ${this.accessLevel}class JSONAny: Codable { ); if (!this._options.justTypes) { - // FIXME: We emit only the MARK line for top-level-enum.schema - if (this._options.convenienceInitializers) { - this.ensureBlankLine(); - this.emitMark("Convenience initializers and mutators"); - this.forEachNamedType( - "leading-and-interposing", - (c: ClassType, className: Name) => this.emitConvenienceInitializersExtension(c, className), - () => undefined, - () => undefined - ); - this.ensureBlankLine(); - this.forEachTopLevel("leading-and-interposing", (t: Type, name: Name) => - this.emitTopLevelMapAndArrayConvenienceInitializerExtensions(t, name) - ); - } - - this.ensureBlankLine(); this.emitSupportFunctions4(); } - - if ( - (!this._options.justTypes && this._options.convenienceInitializers) || - this._options.urlSession || - this._options.alamofire - ) { - this.ensureBlankLine(); - this.emitNewEncoderDecoder(); - } - - if (this._options.urlSession) { - this.ensureBlankLine(); - this.emitMark("URLSession response handlers", true); - this.ensureBlankLine(); - this.emitURLSessionExtension(); - } - - if (this._options.alamofire) { - this.ensureBlankLine(); - this.emitMark("Alamofire response handlers", true); - this.ensureBlankLine(); - this.emitAlamofireExtension(); - } } private emitURLSessionExtension() { diff --git a/test/fixtures/swift/quicktype.swift b/test/fixtures/swift/quicktype.swift index adfca646df..6e4742c0b5 100644 --- a/test/fixtures/swift/quicktype.swift +++ b/test/fixtures/swift/quicktype.swift @@ -1,3 +1,4 @@ +// This file was generated from JSON Schema using quicktype, do not modify it directly. // To parse the JSON, add this file to your project and do: // // guard let topLevel = try TopLevel(json) else { ... } diff --git a/test/languages.ts b/test/languages.ts index 0177230252..bf0e2d0c9c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -96,9 +96,7 @@ export const JavaLanguage: Language = { skipMiscJSON: false, skipSchema: ["keyword-unions.schema"], // generates classes with names that are case-insensitively equal rendererOptions: {}, - quickTestRendererOptions: [ - { "array-type": "list" } - ], + quickTestRendererOptions: [{ "array-type": "list" }], sourceFiles: ["src/language/Java.ts"] }; @@ -488,7 +486,8 @@ export const SwiftLanguage: Language = { "e8b04.json", "f6a65.json", // date-time issues "fcca3.json", - "f82d9.json" + "f82d9.json", + "bug863.json" // Unable to resolve reserved keyword use, "description" ], allowMissingNull: true, features: ["enum", "union", "no-defaults", "date-time"],