diff --git a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts index bd1ebe3d1e..a47b189df1 100644 --- a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts +++ b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts @@ -500,6 +500,8 @@ export class SwiftRenderer extends ConvenienceRenderer { const isClass = this._options.useClasses || this.isCycleBreakerType(c); const structOrClass = isClass ? "class" : "struct"; + const finalPrefix = + isClass && this._options.finalClasses ? "final " : ""; if (isClass && this._options.objcSupport) { // [Michael Fey (@MrRooni), 2019-4-24] Swift 5 or greater, must come before the access declaration for the class. @@ -508,6 +510,7 @@ export class SwiftRenderer extends ConvenienceRenderer { this.emitBlockWithAccess( [ + finalPrefix, structOrClass, " ", className, @@ -1163,11 +1166,13 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); this.emitLine( this.objcMembersDeclaration, this.accessLevel, + this._options.finalClasses ? "final " : "", "class JSONNull: NSObject, Codable {", ); } else { this.emitLine( this.accessLevel, + this._options.finalClasses ? "final " : "", "class JSONNull: Codable, Hashable {", ); } @@ -1214,7 +1219,7 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); if (this._needAny) { this.ensureBlankLine(); - this.emitMultiline(`class JSONCodingKey: CodingKey { + this.emitMultiline(`${this._options.finalClasses ? "final " : ""}class JSONCodingKey: CodingKey { let key: String required init?(intValue: Int) { @@ -1239,10 +1244,15 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); this.emitLine( this.objcMembersDeclaration, this.accessLevel, + this._options.finalClasses ? "final " : "", "class JSONAny: NSObject, Codable {", ); } else { - this.emitLine(this.accessLevel, "class JSONAny: Codable {"); + this.emitLine( + this.accessLevel, + this._options.finalClasses ? "final " : "", + "class JSONAny: Codable {", + ); } this.ensureBlankLine(); diff --git a/packages/quicktype-core/src/language/Swift/language.ts b/packages/quicktype-core/src/language/Swift/language.ts index de936322fd..1e6a53b493 100644 --- a/packages/quicktype-core/src/language/Swift/language.ts +++ b/packages/quicktype-core/src/language/Swift/language.ts @@ -54,6 +54,11 @@ export const swiftOptions = { } as const, "struct", ), + finalClasses: new BooleanOption( + "final-classes", + "Mark classes as final", + false, + ), mutableProperties: new BooleanOption( "mutable-properties", "Use var instead of let for object properties", diff --git a/test/languages.ts b/test/languages.ts index a84d020187..e268fae95f 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -769,6 +769,10 @@ export const SwiftLanguage: Language = { quickTestRendererOptions: [ { "support-linux": "false" }, { "struct-or-class": "class" }, + [ + "simple-object.json", + { "struct-or-class": "class", "final-classes": "true" }, + ], { density: "dense" }, { density: "normal" }, { "access-level": "internal" }, diff --git a/test/unit/swift-final-classes.test.ts b/test/unit/swift-final-classes.test.ts new file mode 100644 index 0000000000..6dd680356f --- /dev/null +++ b/test/unit/swift-final-classes.test.ts @@ -0,0 +1,113 @@ +import { + InputData, + JSONSchemaInput, + type RendererOptions, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderSwift( + schema: object, + rendererOptions: RendererOptions = {}, +): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "swift", + rendererOptions, + }); + return result.lines.join("\n"); +} + +function classDeclarations(output: string): string[] { + return output + .split("\n") + .filter((line) => + /^\s*(?:(?:public|internal)\s+)?(?:final\s+)?class\s+/.test(line), + ); +} + +describe("Swift class generation", () => { + const modelSchema = { + type: "object", + properties: { + child: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + required: ["child"], + }; + const recursiveSchema = { + $ref: "#/definitions/Node", + definitions: { + Node: { + type: "object", + properties: { + next: { $ref: "#/definitions/Node" }, + value: {}, + }, + required: ["value"], + }, + }, + }; + + test("keeps classes open by default", async () => { + const modelOutput = await renderSwift(modelSchema, { + "struct-or-class": "class", + }); + const recursiveOutput = await renderSwift(recursiveSchema); + const declarations = classDeclarations( + `${modelOutput}\n${recursiveOutput}`, + ); + + expect(declarations.length).toBeGreaterThan(0); + expect( + declarations.every((line) => !line.includes("final class")), + ).toBe(true); + }); + + test("marks requested model classes as final when enabled", async () => { + const output = await renderSwift(modelSchema, { + "struct-or-class": "class", + "final-classes": "true", + }); + const declarations = classDeclarations(output); + + expect(declarations).toHaveLength(2); + expect(declarations).toEqual( + expect.arrayContaining([ + expect.stringContaining("final class TopLevel"), + expect.stringContaining("final class Child"), + ]), + ); + }); + + test("marks cycle breakers and Codable helper classes as final", async () => { + const output = await renderSwift(recursiveSchema, { + "final-classes": "true", + }); + const declarations = classDeclarations(output); + + expect(declarations).toEqual( + expect.arrayContaining([ + expect.stringContaining("final class TopLevel"), + expect.stringContaining("final class JSONNull"), + expect.stringContaining("final class JSONCodingKey"), + expect.stringContaining("final class JSONAny"), + ]), + ); + expect(declarations.every((line) => line.includes("final class"))).toBe( + true, + ); + }); +});