From ceccd0072dff6c79bc54b91aff84397a98c8691b Mon Sep 17 00:00:00 2001 From: Lily Ballard Date: Sun, 8 Sep 2019 15:58:30 -0700 Subject: [PATCH] swift: Revamp source printing to ensure escaping everywhere The source generator now operates using a string wrapper type called `SwiftSource`. Identifiers are escaped when converting into `SwiftSource` values, with an alternative method for producing escaped string literals. An escape hatch is provided for disabling escapes, but the default behavior is to escape all identifiers in the dynamic input. Most `SwiftSource` values are produced by a tagged template literal, which allows for easy mixing of string literals containing Swift keywords and dynamic input that needs escaping. As part of this, rewrite string escaping such that it actually escapes string contents properly. This required fixing a test that had a bad spec enforcing broken behavior. Fixes apollographql/apollo-ios#193. Fixes apollographql/apollo-ios#752. --- .../__snapshots__/codeGeneration.ts.snap | 2 +- .../src/__tests__/codeGeneration.ts | 26 +- .../src/__tests__/language.ts | 113 ++++- .../src/codeGeneration.ts | 413 ++++++++++-------- packages/apollo-codegen-swift/src/helpers.ts | 90 ++-- packages/apollo-codegen-swift/src/language.ts | 336 +++++++++++--- 6 files changed, 688 insertions(+), 292 deletions(-) diff --git a/packages/apollo-codegen-swift/src/__tests__/__snapshots__/codeGeneration.ts.snap b/packages/apollo-codegen-swift/src/__tests__/__snapshots__/codeGeneration.ts.snap index 82cf0fa397..cde245a35d 100644 --- a/packages/apollo-codegen-swift/src/__tests__/__snapshots__/codeGeneration.ts.snap +++ b/packages/apollo-codegen-swift/src/__tests__/__snapshots__/codeGeneration.ts.snap @@ -108,7 +108,7 @@ exports[`Swift code generation #classDeclarationForOperation() should correctly /// } /// } public let operationDefinition = - \\"mutation CreateReview($episode: Episode) {\\\\n createReview(episode: $episode, review: {stars: 5, commentary: \\\\\\"\\\\\\"\\\\\\"\\\\n Wow!\\\\n I thought\\\\n This movie \\\\ ROCKED!\\\\n \\\\\\"\\\\\\"\\\\\\"}) {\\\\n stars\\\\n commentary\\\\n }\\\\n}\\" + \\"mutation CreateReview($episode: Episode) {\\\\n createReview(episode: $episode, review: {stars: 5, commentary: \\\\\\"\\\\\\"\\\\\\"\\\\n Wow!\\\\n I thought\\\\n This movie \\\\\\\\ ROCKED!\\\\n \\\\\\"\\\\\\"\\\\\\"}) {\\\\n stars\\\\n commentary\\\\n }\\\\n}\\" public let operationName = \\"CreateReview\\" diff --git a/packages/apollo-codegen-swift/src/__tests__/codeGeneration.ts b/packages/apollo-codegen-swift/src/__tests__/codeGeneration.ts index f2362bed05..3d3da7e99f 100644 --- a/packages/apollo-codegen-swift/src/__tests__/codeGeneration.ts +++ b/packages/apollo-codegen-swift/src/__tests__/codeGeneration.ts @@ -222,7 +222,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: GraphQLString - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -232,7 +232,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLNonNull(GraphQLString) - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -242,7 +242,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLList(GraphQLString) - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -252,7 +252,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLList(new GraphQLNonNull(GraphQLString)) - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -262,7 +262,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLNonNull(new GraphQLList(GraphQLString)) - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -274,7 +274,7 @@ describe("Swift code generation", () => { type: new GraphQLNonNull( new GraphQLList(new GraphQLNonNull(GraphQLString)) ) - }) + }).source ).toBe('"response_key": propertyName'); }); @@ -284,7 +284,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: schema.getType("Droid") - }) + }).source ).toBe( '"response_key": propertyName.flatMap { (value: Droid) -> ResultMap in value.resultMap }' ); @@ -296,7 +296,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLNonNull(schema.getType("Droid")) - }) + }).source ).toBe('"response_key": propertyName.resultMap'); }); @@ -306,7 +306,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLList(schema.getType("Droid")) - }) + }).source ).toBe( '"response_key": propertyName.flatMap { (value: [Droid?]) -> [ResultMap?] in value.map { (value: Droid?) -> ResultMap? in value.flatMap { (value: Droid) -> ResultMap in value.resultMap } } }' ); @@ -318,7 +318,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLList(new GraphQLNonNull(schema.getType("Droid"))) - }) + }).source ).toBe( '"response_key": propertyName.flatMap { (value: [Droid]) -> [ResultMap] in value.map { (value: Droid) -> ResultMap in value.resultMap } }' ); @@ -330,7 +330,7 @@ describe("Swift code generation", () => { responseKey: "response_key", propertyName: "propertyName", type: new GraphQLNonNull(new GraphQLList(schema.getType("Droid"))) - }) + }).source ).toBe( '"response_key": propertyName.map { (value: Droid?) -> ResultMap? in value.flatMap { (value: Droid) -> ResultMap in value.resultMap } }' ); @@ -344,7 +344,7 @@ describe("Swift code generation", () => { type: new GraphQLNonNull( new GraphQLList(new GraphQLNonNull(schema.getType("Droid"))) ) - }) + }).source ).toBe( '"response_key": propertyName.map { (value: Droid) -> ResultMap in value.resultMap }' ); @@ -661,7 +661,7 @@ describe("Swift code generation", () => { .selectionSet.selections[0] as Field).args as Argument[]; const dictionaryLiteral = generator.helpers.dictionaryLiteralForFieldArguments( fieldArguments - ); + ).source; expect(dictionaryLiteral).toBe( '["episode": "JEDI", "review": ["stars": 2, "commentary": GraphQLVariable("commentary"), "favorite_color": ["red": GraphQLVariable("red"), "blue": 100, "green": 50]]]' diff --git a/packages/apollo-codegen-swift/src/__tests__/language.ts b/packages/apollo-codegen-swift/src/__tests__/language.ts index 0afdd752af..b19c985385 100644 --- a/packages/apollo-codegen-swift/src/__tests__/language.ts +++ b/packages/apollo-codegen-swift/src/__tests__/language.ts @@ -1,6 +1,7 @@ import { stripIndent } from "common-tags"; -import { SwiftGenerator } from "../language"; +import { SwiftGenerator, SwiftSource, swift } from "../language"; +import { valueFromAST } from "graphql"; describe("Swift code generation: Basic language constructs", () => { let generator: SwiftGenerator; @@ -264,3 +265,113 @@ describe("Swift code generation: Basic language constructs", () => { expect(generator.output).toMatchSnapshot(); }); }); + +describe("Swift code generation: Escaping", () => { + describe("using SwiftSource", () => { + it(`should escape identifiers`, () => { + expect(SwiftSource.identifier("self").source).toBe("`self`"); + expect(SwiftSource.identifier("public").source).toBe("`public`"); + expect(SwiftSource.identifier("Array").source).toBe( + "Array<`Type`>" + ); + expect(SwiftSource.identifier("[Self?]?").source).toBe("[`Self`?]?"); + }); + + it(`should not escape other words`, () => { + expect(SwiftSource.identifier("me").source).toBe("me"); + expect(SwiftSource.identifier("_Self").source).toBe("_Self"); + expect(SwiftSource.identifier("classes").source).toBe("classes"); + }); + + it(`should escape fewer words in member position`, () => { + expect(SwiftSource.identifier(".self").source).toBe(".`self`"); + expect(SwiftSource.identifier(".public").source).toBe(".public"); + expect(SwiftSource.identifier("Foo.Self.Type.self.class").source).toBe( + "Foo.Self.`Type`.`self`.class" + ); + }); + + it(`should escape fewer words at offset 0 with member escaping`, () => { + expect(SwiftSource.memberName("self").source).toBe("`self`"); + expect(SwiftSource.memberName("public").source).toBe("public"); + expect(SwiftSource.memberName(" public").source).toBe(" `public`"); + expect(SwiftSource.memberName("Foo.Self.Type.self.class").source).toBe( + "Foo.Self.`Type`.`self`.class" + ); + }); + + it(`should escape strings`, () => { + expect(SwiftSource.string("foobar").source).toBe('"foobar"'); + expect(SwiftSource.string("foo\n bar ").source).toBe('"foo\\n bar "'); + expect(SwiftSource.string("one'two\"three\\four\tfive").source).toBe( + '"one\'two\\"three\\\\four\\tfive"' + ); + }); + + it(`should trim strings when asked`, () => { + expect(SwiftSource.string("foobar", true).source).toBe('"foobar"'); + expect(SwiftSource.string("foo\n bar ", true).source).toBe('"foo bar"'); + }); + + it(`should support concatenation`, () => { + expect(swift`one`.concat().source).toBe("one"); + expect(swift`one`.concat(swift`two`).source).toBe("onetwo"); + expect(swift`one`.concat(swift`two`, swift`three`).source).toBe( + "onetwothree" + ); + }); + + it(`should support appending`, () => { + let value = swift`one`; + value.append(); + expect(value.source).toBe("one"); + value.append(swift`foo`); + expect(value.source).toBe("onefoo"); + value.append(swift`bar`, swift`baz`, swift`qux`); + expect(value.source).toBe("onefoobarbazqux"); + }); + }); + describe("using SwiftGenerator", () => { + let generator: SwiftGenerator; + + beforeEach(() => { + generator = new SwiftGenerator({}); + }); + + it(`should trim with multilineString`, () => { + generator.multilineString("foo\n bar "); + + expect(generator.output).toBe('"foo bar"'); + }); + + it(`shouldn't trim with multilineString when using """`, () => { + generator.multilineString('"""\nfoo\n bar \n"""'); + expect(generator.output).toBe('"\\"\\"\\"\\nfoo\\n bar \\n\\"\\"\\""'); + }); + }); + describe("using template strings", () => { + it(`should escape interpolated strings but not string literals`, () => { + expect(swift`self`.source).toBe("self"); + expect(swift`${"self"}`.source).toBe("`self`"); + expect(swift`class ${"Foo.Type.self"}: ${"Protocol?"}`.source).toBe( + "class Foo.`Type`.`self`: `Protocol`?" + ); + expect(swift`${["Self", "Foo.Self.self"]}`.source).toBe( + "`Self`,Foo.Self.`self`" + ); + expect(swift`${true} ${"true"}`.source).toBe("true `true`"); + expect(swift`${{ toString: () => "self" }}`.source).toBe("`self`"); + }); + + it(`should not escape already-escaped interpolated strings`, () => { + expect(swift`${swift`${"self"}`}`.source).toBe("`self`"); + expect(swift`${"public"} ${new SwiftSource("public")}`.source).toBe( + "`public` public" + ); + }); + + it(`should not escape with the raw tag`, () => { + expect(SwiftSource.raw`${"self"}`.source).toBe("self"); + }); + }); +}); diff --git a/packages/apollo-codegen-swift/src/codeGeneration.ts b/packages/apollo-codegen-swift/src/codeGeneration.ts index 95d892b29c..d69a91ec1a 100644 --- a/packages/apollo-codegen-swift/src/codeGeneration.ts +++ b/packages/apollo-codegen-swift/src/codeGeneration.ts @@ -19,13 +19,12 @@ import { Field } from "apollo-codegen-core/lib/compiler"; -import { join, wrap } from "apollo-codegen-core/lib/utilities/printing"; - import { SwiftGenerator, Property, - escapeIdentifierIfNeeded, - Struct + Struct, + SwiftSource, + swift } from "./language"; import { Helpers } from "./helpers"; import { isList } from "apollo-codegen-core/lib/utilities/graphql"; @@ -41,6 +40,8 @@ import { collectAndMergeFields } from "apollo-codegen-core/lib/compiler/visitors import "apollo-codegen-core/lib/utilities/array"; +const { join, wrap } = SwiftSource; + export interface Options { namespace?: string; passthroughCustomScalars?: boolean; @@ -140,10 +141,10 @@ export class SwiftAPIGenerator extends SwiftGenerator { fileHeader() { this.printOnNewline( - "// This file was automatically generated and should not be edited." + SwiftSource.raw`// This file was automatically generated and should not be edited.` ); this.printNewline(); - this.printOnNewline("import Apollo"); + this.printOnNewline(swift`import Apollo`); } /** @@ -203,14 +204,16 @@ export class SwiftAPIGenerator extends SwiftGenerator { () => { if (source) { this.commentWithoutTrimming(source); - this.printOnNewline("public let operationDefinition ="); + this.printOnNewline(swift`public let operationDefinition =`); this.withIndent(() => { this.multilineString(source); }); } this.printNewlineIfNeeded(); - this.printOnNewline(`public let operationName = "${operationName}"`); + this.printOnNewline( + swift`public let operationName = ${SwiftSource.string(operationName)}` + ); const fragmentsReferenced = collectFragmentsReferenced( operation.selectionSet, @@ -226,23 +229,25 @@ export class SwiftAPIGenerator extends SwiftGenerator { operation.operationId = operationId; this.printNewlineIfNeeded(); this.printOnNewline( - `public let operationIdentifier: String? = "${operationId}"` + swift`public let operationIdentifier: String? = ${SwiftSource.string( + operationId + )}` ); } if (fragmentsReferenced.size > 0) { this.printNewlineIfNeeded(); this.printOnNewline( - "public var queryDocument: String { return operationDefinition" + swift`public var queryDocument: String { return operationDefinition` ); fragmentsReferenced.forEach(fragmentName => { this.print( - `.appending(${this.helpers.structNameForFragmentName( + swift`.appending(${this.helpers.structNameForFragmentName( fragmentName )}.fragmentDefinition)` ); }); - this.print(" }"); + this.print(swift` }`); } this.printNewlineIfNeeded(); @@ -263,19 +268,19 @@ export class SwiftAPIGenerator extends SwiftGenerator { this.initializerDeclarationForProperties(properties); this.printNewlineIfNeeded(); - this.printOnNewline(`public var variables: GraphQLMap?`); + this.printOnNewline(swift`public var variables: GraphQLMap?`); this.withinBlock(() => { this.printOnNewline( wrap( - `return [`, + swift`return [`, join( properties.map( ({ name, propertyName }) => - `"${name}": ${escapeIdentifierIfNeeded(propertyName)}` + swift`${SwiftSource.string(name)}: ${propertyName}` ), ", " ) || ":", - `]` + swift`]` ) ); }); @@ -317,7 +322,7 @@ export class SwiftAPIGenerator extends SwiftGenerator { () => { if (source) { this.commentWithoutTrimming(source); - this.printOnNewline("public static let fragmentDefinition ="); + this.printOnNewline(swift`public static let fragmentDefinition =`); this.withIndent(() => { this.multilineString(source); }); @@ -420,15 +425,20 @@ export class SwiftAPIGenerator extends SwiftGenerator { } this.printNewlineIfNeeded(); - this.printOnNewline("public static let possibleTypes = ["); + this.printOnNewline(swift`public static let possibleTypes = [`); this.print( - join(variant.possibleTypes.map(type => `"${type.name}"`), ", ") + join( + variant.possibleTypes.map( + type => swift`${SwiftSource.string(type.name)}` + ), + ", " + ) ); - this.print("]"); + this.print(swift`]`); this.printNewlineIfNeeded(); this.printOnNewline( - "public static let selections: [GraphQLSelection] = " + swift`public static let selections: [GraphQLSelection] = ` ); if (typeCase) { this.typeCaseInitialization(typeCase); @@ -438,12 +448,14 @@ export class SwiftAPIGenerator extends SwiftGenerator { this.printNewlineIfNeeded(); - this.printOnNewline(`public private(set) var resultMap: ResultMap`); + this.printOnNewline( + swift`public private(set) var resultMap: ResultMap` + ); this.printNewlineIfNeeded(); - this.printOnNewline("public init(unsafeResultMap: ResultMap)"); + this.printOnNewline(swift`public init(unsafeResultMap: ResultMap)`); this.withinBlock(() => { - this.printOnNewline(`self.resultMap = unsafeResultMap`); + this.printOnNewline(swift`self.resultMap = unsafeResultMap`); }); if (typeCase) { @@ -472,17 +484,17 @@ export class SwiftAPIGenerator extends SwiftGenerator { if (fragmentSpreads.length > 0) { this.printNewlineIfNeeded(); - this.printOnNewline(`public var fragments: Fragments`); + this.printOnNewline(swift`public var fragments: Fragments`); this.withinBlock(() => { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { this.printOnNewline( - `return Fragments(unsafeResultMap: resultMap)` + swift`return Fragments(unsafeResultMap: resultMap)` ); }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { - this.printOnNewline(`resultMap += newValue.resultMap`); + this.printOnNewline(swift`resultMap += newValue.resultMap`); }); }); @@ -493,13 +505,15 @@ export class SwiftAPIGenerator extends SwiftGenerator { outputIndividualFiles, () => { this.printOnNewline( - `public private(set) var resultMap: ResultMap` + swift`public private(set) var resultMap: ResultMap` ); this.printNewlineIfNeeded(); - this.printOnNewline("public init(unsafeResultMap: ResultMap)"); + this.printOnNewline( + swift`public init(unsafeResultMap: ResultMap)` + ); this.withinBlock(() => { - this.printOnNewline(`self.resultMap = unsafeResultMap`); + this.printOnNewline(swift`self.resultMap = unsafeResultMap`); }); for (const fragmentSpread of fragmentSpreads) { @@ -512,31 +526,33 @@ export class SwiftAPIGenerator extends SwiftGenerator { this.printNewlineIfNeeded(); this.printOnNewline( - `public var ${escapeIdentifierIfNeeded( - propertyName - )}: ${typeName}` + swift`public var ${propertyName}: ${typeName}` ); this.withinBlock(() => { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { if (isConditional) { this.printOnNewline( - `if !${structName}.possibleTypes.contains(resultMap["__typename"]! as! String) { return nil }` + swift`if !${structName}.possibleTypes.contains(resultMap["__typename"]! as! String) { return nil }` ); } this.printOnNewline( - `return ${structName}(unsafeResultMap: resultMap)` + swift`return ${structName}(unsafeResultMap: resultMap)` ); }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { if (isConditional) { this.printOnNewline( - `guard let newValue = newValue else { return }` + swift`guard let newValue = newValue else { return }` + ); + this.printOnNewline( + swift`resultMap += newValue.resultMap` ); - this.printOnNewline(`resultMap += newValue.resultMap`); } else { - this.printOnNewline(`resultMap += newValue.resultMap`); + this.printOnNewline( + swift`resultMap += newValue.resultMap` + ); } }); }); @@ -593,22 +609,24 @@ export class SwiftAPIGenerator extends SwiftGenerator { if (!properties) return; this.printNewlineIfNeeded(); - this.printOnNewline(`public init`); + this.printOnNewline(swift`public init`); this.parametersForProperties(properties); this.withinBlock(() => { this.printOnNewline( wrap( - `self.init(unsafeResultMap: [`, + swift`self.init(unsafeResultMap: [`, join( [ - `"__typename": "${variant.possibleTypes[0]}"`, + swift`"__typename": ${SwiftSource.string( + variant.possibleTypes[0].toString() + )}`, ...properties.map(this.propertyAssignmentForField, this) ], ", " ) || ":", - `])` + swift`])` ) ); }); @@ -627,24 +645,28 @@ export class SwiftAPIGenerator extends SwiftGenerator { if (!properties) continue; this.printNewlineIfNeeded(); - this.printOnNewline(`public static func make${possibleType}`); + this.printOnNewline( + SwiftSource.raw`public static func make${possibleType}` + ); this.parametersForProperties(properties); - this.print(` -> ${structName}`); + this.print(swift` -> ${structName}`); this.withinBlock(() => { this.printOnNewline( wrap( - `return ${structName}(unsafeResultMap: [`, + swift`return ${structName}(unsafeResultMap: [`, join( [ - `"__typename": "${possibleType}"`, + swift`"__typename": ${SwiftSource.string( + possibleType.toString() + )}`, ...properties.map(this.propertyAssignmentForField, this) ], ", " ) || ":", - `])` + swift`])` ) ); }); @@ -658,7 +680,7 @@ export class SwiftAPIGenerator extends SwiftGenerator { type: GraphQLType; isConditional?: boolean; structName?: string; - }) { + }): SwiftSource { const { responseKey, propertyName, @@ -670,13 +692,13 @@ export class SwiftAPIGenerator extends SwiftGenerator { ? this.helpers.mapExpressionForType( type, isConditional, - expression => `${expression}.resultMap`, - escapeIdentifierIfNeeded(propertyName), + expression => swift`${expression}.resultMap`, + SwiftSource.identifier(propertyName), structName!, "ResultMap" ) - : escapeIdentifierIfNeeded(propertyName); - return `"${responseKey}": ${valueExpression}`; + : SwiftSource.identifier(propertyName); + return swift`${SwiftSource.string(responseKey)}: ${valueExpression}`; } propertyDeclarationForField(field: Field & Property) { @@ -696,17 +718,13 @@ export class SwiftAPIGenerator extends SwiftGenerator { this.comment(field.description); this.deprecationAttributes(field.isDeprecated, field.deprecationReason); - this.printOnNewline( - `public var ${escapeIdentifierIfNeeded(propertyName)}: ${typeName}` - ); + this.printOnNewline(swift`public var ${propertyName}: ${typeName}`); this.withinBlock(() => { if (isCompositeType(unmodifiedFieldType)) { - const structName = escapeIdentifierIfNeeded( - this.helpers.structNameForPropertyName(propertyName) - ); + const structName = this.helpers.structNameForPropertyName(propertyName); if (isList(type)) { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { const resultMapTypeName = this.helpers.typeNameFromGraphQLType( type, @@ -715,49 +733,60 @@ export class SwiftAPIGenerator extends SwiftGenerator { ); let expression; if (isOptional) { - expression = `(resultMap["${responseKey}"] as? ${resultMapTypeName})`; + expression = swift`(resultMap[${SwiftSource.string( + responseKey + )}] as? ${resultMapTypeName})`; } else { - expression = `(resultMap["${responseKey}"] as! ${resultMapTypeName})`; + expression = swift`(resultMap[${SwiftSource.string( + responseKey + )}] as! ${resultMapTypeName})`; } this.printOnNewline( - `return ${this.helpers.mapExpressionForType( + swift`return ${this.helpers.mapExpressionForType( type, isConditional, - expression => `${structName}(unsafeResultMap: ${expression})`, + expression => + swift`${structName}(unsafeResultMap: ${expression})`, expression, "ResultMap", structName )}` ); }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { let newValueExpression = this.helpers.mapExpressionForType( type, isConditional, - expression => `${expression}.resultMap`, - "newValue", + expression => swift`${expression}.resultMap`, + swift`newValue`, structName, "ResultMap" ); this.printOnNewline( - `resultMap.updateValue(${newValueExpression}, forKey: "${responseKey}")` + swift`resultMap.updateValue(${newValueExpression}, forKey: ${SwiftSource.string( + responseKey + )})` ); }); } else { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { if (isOptional) { this.printOnNewline( - `return (resultMap["${responseKey}"] as? ResultMap).flatMap { ${structName}(unsafeResultMap: $0) }` + swift`return (resultMap[${SwiftSource.string( + responseKey + )}] as? ResultMap).flatMap { ${structName}(unsafeResultMap: $0) }` ); } else { this.printOnNewline( - `return ${structName}(unsafeResultMap: resultMap["${responseKey}"]! as! ResultMap)` + swift`return ${structName}(unsafeResultMap: resultMap[${SwiftSource.string( + responseKey + )}]! as! ResultMap)` ); } }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { let newValueExpression; if (isOptional) { @@ -766,27 +795,35 @@ export class SwiftAPIGenerator extends SwiftGenerator { newValueExpression = "newValue.resultMap"; } this.printOnNewline( - `resultMap.updateValue(${newValueExpression}, forKey: "${responseKey}")` + swift`resultMap.updateValue(${newValueExpression}, forKey: ${SwiftSource.string( + responseKey + )})` ); }); } } else { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { if (isOptional) { this.printOnNewline( - `return resultMap["${responseKey}"] as? ${typeName.slice(0, -1)}` + swift`return resultMap[${SwiftSource.string( + responseKey + )}] as? ${typeName.slice(0, -1)}` ); } else { this.printOnNewline( - `return resultMap["${responseKey}"]! as! ${typeName}` + swift`return resultMap[${SwiftSource.string( + responseKey + )}]! as! ${typeName}` ); } }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { this.printOnNewline( - `resultMap.updateValue(newValue, forKey: "${responseKey}")` + swift`resultMap.updateValue(newValue, forKey: ${SwiftSource.string( + responseKey + )})` ); }); } @@ -797,52 +834,52 @@ export class SwiftAPIGenerator extends SwiftGenerator { const { propertyName, typeName, structName } = variant; this.printNewlineIfNeeded(); - this.printOnNewline( - `public var ${escapeIdentifierIfNeeded(propertyName)}: ${typeName}` - ); + this.printOnNewline(swift`public var ${propertyName}: ${typeName}`); this.withinBlock(() => { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { this.printOnNewline( - `if !${structName}.possibleTypes.contains(__typename) { return nil }` + swift`if !${structName}.possibleTypes.contains(__typename) { return nil }` + ); + this.printOnNewline( + swift`return ${structName}(unsafeResultMap: resultMap)` ); - this.printOnNewline(`return ${structName}(unsafeResultMap: resultMap)`); }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { - this.printOnNewline(`guard let newValue = newValue else { return }`); - this.printOnNewline(`resultMap = newValue.resultMap`); + this.printOnNewline( + swift`guard let newValue = newValue else { return }` + ); + this.printOnNewline(swift`resultMap = newValue.resultMap`); }); }); } initializerDeclarationForProperties(properties: Property[]) { - this.printOnNewline(`public init`); + this.printOnNewline(swift`public init`); this.parametersForProperties(properties); this.withinBlock(() => { properties.forEach(({ propertyName }) => { - this.printOnNewline( - `self.${propertyName} = ${escapeIdentifierIfNeeded(propertyName)}` - ); + this.printOnNewline(swift`self.${propertyName} = ${propertyName}`); }); }); } parametersForProperties(properties: Property[]) { - this.print("("); + this.print(swift`(`); this.print( join( properties.map(({ propertyName, typeName, isOptional }) => join([ - `${escapeIdentifierIfNeeded(propertyName)}: ${typeName}`, - isOptional && " = nil" + swift`${propertyName}: ${typeName}`, + isOptional ? swift` = nil` : undefined ]) ), ", " ) ); - this.print(")"); + this.print(swift`)`); } typeCaseInitialization(typeCase: TypeCase) { @@ -851,32 +888,36 @@ export class SwiftAPIGenerator extends SwiftGenerator { return; } - this.print("["); + this.print(swift`[`); this.withIndent(() => { - this.printOnNewline(`GraphQLTypeCase(`); + this.printOnNewline(swift`GraphQLTypeCase(`); this.withIndent(() => { - this.printOnNewline(`variants: [`); + this.printOnNewline(swift`variants: [`); this.print( - typeCase.variants - .flatMap(variant => { + join( + typeCase.variants.flatMap(variant => { const structName = this.helpers.structNameForVariant(variant); return variant.possibleTypes.map( - type => `"${type}": ${structName}.selections` + type => + swift`${SwiftSource.string( + type.toString() + )}: ${structName}.selections` ); - }) - .join(", ") + }), + ", " + ) ); - this.print("],"); - this.printOnNewline(`default: `); + this.print(swift`],`); + this.printOnNewline(swift`default: `); this.selectionSetInitialization(typeCase.default); }); - this.printOnNewline(")"); + this.printOnNewline(swift`)`); }); - this.printOnNewline("]"); + this.printOnNewline(swift`]`); } selectionSetInitialization(selectionSet: SelectionSet) { - this.print("["); + this.print(swift`[`); this.withIndent(() => { for (const selection of selectionSet.selections) { switch (selection.kind) { @@ -887,71 +928,77 @@ export class SwiftAPIGenerator extends SwiftGenerator { responseKey ); - this.printOnNewline(`GraphQLField(`); + this.printOnNewline(swift`GraphQLField(`); this.print( join( [ - `"${name}"`, - alias ? `alias: "${alias}"` : null, - args && - args.length && - `arguments: ${this.helpers.dictionaryLiteralForFieldArguments( - args - )}`, - `type: ${this.helpers.fieldTypeEnum(type, structName)}` + swift`${SwiftSource.string(name)}`, + alias + ? swift`alias: ${SwiftSource.string(alias)}` + : undefined, + args && args.length + ? swift`arguments: ${this.helpers.dictionaryLiteralForFieldArguments( + args + )}` + : undefined, + swift`type: ${this.helpers.fieldTypeEnum(type, structName)}` ], ", " ) ); - this.print("),"); + this.print(swift`),`); break; } case "BooleanCondition": - this.printOnNewline(`GraphQLBooleanCondition(`); + this.printOnNewline(swift`GraphQLBooleanCondition(`); this.print( join( [ - `variableName: "${selection.variableName}"`, - `inverted: ${selection.inverted}`, - "selections: " + swift`variableName: ${SwiftSource.string( + selection.variableName + )}`, + swift`inverted: ${selection.inverted}`, + swift`selections: ` ], ", " ) ); this.selectionSetInitialization(selection.selectionSet); - this.print("),"); + this.print(swift`),`); break; case "TypeCondition": { - this.printOnNewline(`GraphQLTypeCondition(`); + this.printOnNewline(swift`GraphQLTypeCondition(`); this.print( join( [ - `possibleTypes: [${join( + swift`possibleTypes: [${join( selection.selectionSet.possibleTypes.map( - type => `"${type.name}"` + type => swift`${SwiftSource.string(type.name)}` ), ", " )}]`, - "selections: " + swift`selections: ` ], ", " ) ); this.selectionSetInitialization(selection.selectionSet); - this.print("),"); + this.print(swift`),`); break; } case "FragmentSpread": { const structName = this.helpers.structNameForFragmentName( selection.fragmentName ); - this.printOnNewline(`GraphQLFragmentSpread(${structName}.self),`); + this.printOnNewline( + swift`GraphQLFragmentSpread(${structName}.self),` + ); break; } } } }); - this.printOnNewline("]"); + this.printOnNewline(swift`]`); } /** @@ -979,10 +1026,10 @@ export class SwiftAPIGenerator extends SwiftGenerator { this.printNewlineIfNeeded(); this.comment(description || undefined); this.printOnNewline( - `public enum ${name}: RawRepresentable, Equatable, Hashable, CaseIterable, Apollo.JSONDecodable, Apollo.JSONEncodable` + swift`public enum ${name}: RawRepresentable, Equatable, Hashable, CaseIterable, Apollo.JSONDecodable, Apollo.JSONEncodable` ); this.withinBlock(() => { - this.printOnNewline("public typealias RawValue = String"); + this.printOnNewline(swift`public typealias RawValue = String`); values.forEach(value => { this.comment(value.description || undefined); @@ -991,80 +1038,74 @@ export class SwiftAPIGenerator extends SwiftGenerator { value.deprecationReason || undefined ); this.printOnNewline( - `case ${escapeIdentifierIfNeeded( - this.helpers.enumCaseName(value.name) - )}` + swift`case ${this.helpers.enumCaseName(value.name)}` ); }); this.comment("Auto generated constant for unknown enum values"); - this.printOnNewline("case __unknown(RawValue)"); + this.printOnNewline(swift`case __unknown(RawValue)`); this.printNewlineIfNeeded(); - this.printOnNewline("public init?(rawValue: RawValue)"); + this.printOnNewline(swift`public init?(rawValue: RawValue)`); this.withinBlock(() => { - this.printOnNewline("switch rawValue"); + this.printOnNewline(swift`switch rawValue`); this.withinBlock(() => { values.forEach(value => { this.printOnNewline( - `case "${value.value}": self = ${escapeIdentifierIfNeeded( - this.helpers.enumDotCaseName(value.name) - )}` + swift`case ${SwiftSource.string( + value.value + )}: self = ${this.helpers.enumDotCaseName(value.name)}` ); }); - this.printOnNewline(`default: self = .__unknown(rawValue)`); + this.printOnNewline(swift`default: self = .__unknown(rawValue)`); }); }); this.printNewlineIfNeeded(); - this.printOnNewline("public var rawValue: RawValue"); + this.printOnNewline(swift`public var rawValue: RawValue`); this.withinBlock(() => { - this.printOnNewline("switch self"); + this.printOnNewline(swift`switch self`); this.withinBlock(() => { values.forEach(value => { this.printOnNewline( - `case ${escapeIdentifierIfNeeded( - this.helpers.enumDotCaseName(value.name) - )}: return "${value.value}"` + swift`case ${this.helpers.enumDotCaseName( + value.name + )}: return ${SwiftSource.string(value.value)}` ); }); - this.printOnNewline(`case .__unknown(let value): return value`); + this.printOnNewline(swift`case .__unknown(let value): return value`); }); }); this.printNewlineIfNeeded(); this.printOnNewline( - `public static func == (lhs: ${name}, rhs: ${name}) -> Bool` + swift`public static func == (lhs: ${name}, rhs: ${name}) -> Bool` ); this.withinBlock(() => { - this.printOnNewline("switch (lhs, rhs)"); + this.printOnNewline(swift`switch (lhs, rhs)`); this.withinBlock(() => { values.forEach(value => { - const enumDotCaseName = escapeIdentifierIfNeeded( - this.helpers.enumDotCaseName(value.name) - ); - const tuple = `(${enumDotCaseName}, ${enumDotCaseName})`; - this.printOnNewline(`case ${tuple}: return true`); + const enumDotCaseName = this.helpers.enumDotCaseName(value.name); + const tuple = swift`(${enumDotCaseName}, ${enumDotCaseName})`; + this.printOnNewline(swift`case ${tuple}: return true`); }); this.printOnNewline( - `case (.__unknown(let lhsValue), .__unknown(let rhsValue)): return lhsValue == rhsValue` + swift`case (.__unknown(let lhsValue), .__unknown(let rhsValue)): return lhsValue == rhsValue` ); - this.printOnNewline(`default: return false`); + this.printOnNewline(swift`default: return false`); }); }); this.printNewlineIfNeeded(); - this.printOnNewline(`public static var allCases: [${name}]`); + this.printOnNewline(swift`public static var allCases: [${name}]`); this.withinBlock(() => { - this.printOnNewline(`return [`); + this.printOnNewline(swift`return [`); values.forEach(value => { - const enumDotCaseName = escapeIdentifierIfNeeded( - this.helpers.enumDotCaseName(value.name) - ); + const enumDotCaseName = this.helpers.enumDotCaseName(value.name); this.withIndent(() => { - this.printOnNewline(`${enumDotCaseName},`); + this.printOnNewline(swift`${enumDotCaseName},`); }); }); - this.printOnNewline(`]`); + this.printOnNewline(swift`]`); }); }); } @@ -1099,36 +1140,36 @@ export class SwiftAPIGenerator extends SwiftGenerator { { structName, description: description || undefined, adoptedProtocols }, outputIndividualFiles, () => { - this.printOnNewline(`public var graphQLMap: GraphQLMap`); + this.printOnNewline(swift`public var graphQLMap: GraphQLMap`); this.printNewlineIfNeeded(); - this.printOnNewline(`public init`); - this.print("("); + this.printOnNewline(swift`public init`); + this.print(swift`(`); this.print( join( properties.map(({ propertyName, typeName, isOptional }) => join([ - `${escapeIdentifierIfNeeded(propertyName)}: ${typeName}`, - isOptional && " = nil" + swift`${propertyName}: ${typeName}`, + isOptional ? swift` = nil` : undefined ]) ), ", " ) ); - this.print(")"); + this.print(swift`)`); this.withinBlock(() => { this.printOnNewline( wrap( - `graphQLMap = [`, + swift`graphQLMap = [`, join( properties.map( ({ name, propertyName }) => - `"${name}": ${escapeIdentifierIfNeeded(propertyName)}` + swift`${SwiftSource.string(name)}: ${propertyName}` ), ", " ) || ":", - `]` + swift`]` ) ); }); @@ -1142,26 +1183,30 @@ export class SwiftAPIGenerator extends SwiftGenerator { } of properties) { this.printNewlineIfNeeded(); this.comment(description || undefined); - this.printOnNewline( - `public var ${escapeIdentifierIfNeeded(propertyName)}: ${typeName}` - ); + this.printOnNewline(swift`public var ${propertyName}: ${typeName}`); this.withinBlock(() => { - this.printOnNewline("get"); + this.printOnNewline(swift`get`); this.withinBlock(() => { if (isOptional) { this.printOnNewline( - `return graphQLMap["${name}"] as? ${typeName} ?? .none` + swift`return graphQLMap[${SwiftSource.string( + name + )}] as? ${typeName} ?? .none` ); } else { this.printOnNewline( - `return graphQLMap["${name}"] as! ${typeName}` + swift`return graphQLMap[${SwiftSource.string( + name + )}] as! ${typeName}` ); } }); - this.printOnNewline("set"); + this.printOnNewline(swift`set`); this.withinBlock(() => { this.printOnNewline( - `graphQLMap.updateValue(newValue, forKey: "${name}")` + swift`graphQLMap.updateValue(newValue, forKey: ${SwiftSource.string( + name + )})` ); }); }); diff --git a/packages/apollo-codegen-swift/src/helpers.ts b/packages/apollo-codegen-swift/src/helpers.ts index 2325b58636..fc7a1ddb1c 100644 --- a/packages/apollo-codegen-swift/src/helpers.ts +++ b/packages/apollo-codegen-swift/src/helpers.ts @@ -19,7 +19,7 @@ import { camelCase, pascalCase } from "change-case"; import * as Inflector from "inflected"; import { join, wrap } from "apollo-codegen-core/lib/utilities/printing"; -import { Property, Struct } from "./language"; +import { Property, Struct, SwiftSource, swift } from "./language"; import { CompilerOptions, @@ -32,6 +32,10 @@ import { isMetaFieldName } from "apollo-codegen-core/lib/utilities/graphql"; import { Variant } from "apollo-codegen-core/lib/compiler/visitors/typeCase"; import { collectAndMergeFields } from "apollo-codegen-core/lib/compiler/visitors/collectAndMergeFields"; +// In this file, most functions work with strings, but anything that takes or receives an +// expression uses `SwiftSource`. This way types and names stay represented as strings for as long as +// possible. + const builtInScalarMap = { [GraphQLString.name]: "String", [GraphQLInt.name]: "Int", @@ -84,17 +88,17 @@ export class Helpers { ); } - fieldTypeEnum(type: GraphQLType, structName: string): string { + fieldTypeEnum(type: GraphQLType, structName: string): SwiftSource { if (isNonNullType(type)) { - return `.nonNull(${this.fieldTypeEnum(type.ofType, structName)})`; + return swift`.nonNull(${this.fieldTypeEnum(type.ofType, structName)})`; } else if (isListType(type)) { - return `.list(${this.fieldTypeEnum(type.ofType, structName)})`; + return swift`.list(${this.fieldTypeEnum(type.ofType, structName)})`; } else if (type instanceof GraphQLScalarType) { - return `.scalar(${this.typeNameForScalarType(type)}.self)`; + return swift`.scalar(${this.typeNameForScalarType(type)}.self)`; } else if (type instanceof GraphQLEnumType) { - return `.scalar(${type.name}.self)`; + return swift`.scalar(${type.name}.self)`; } else if (isCompositeType(type)) { - return `.object(${structName}.selections)`; + return swift`.object(${structName}.selections)`; } else { throw new Error(`Unknown field type: ${type}`); } @@ -106,8 +110,8 @@ export class Helpers { return camelCase(name); } - enumDotCaseName(name: string) { - return `.${camelCase(name)}`; + enumDotCaseName(name: string): SwiftSource { + return swift`.${SwiftSource.memberName(camelCase(name))}`; } operationClassName(name: string) { @@ -228,48 +232,60 @@ export class Helpers { // Expressions - dictionaryLiteralForFieldArguments(args: Argument[]) { - function expressionFromValue(value: any): string { + dictionaryLiteralForFieldArguments(args: Argument[]): SwiftSource { + function expressionFromValue(value: any): SwiftSource { if (value.kind === "Variable") { - return `GraphQLVariable("${value.variableName}")`; + return swift`GraphQLVariable(${SwiftSource.string( + value.variableName + )})`; } else if (Array.isArray(value)) { - return wrap("[", join(value.map(expressionFromValue), ", "), "]"); + return SwiftSource.wrap( + swift`[`, + SwiftSource.join(value.map(expressionFromValue), ", "), + swift`]` + ); } else if (typeof value === "object") { - return wrap( - "[", - join( + return SwiftSource.wrap( + swift`[`, + SwiftSource.join( Object.entries(value).map(([key, value]) => { - return `"${key}": ${expressionFromValue(value)}`; + return swift`${SwiftSource.string(key)}: ${expressionFromValue( + value + )}`; }), ", " ) || ":", - "]" + swift`]` ); + } else if (typeof value === "string") { + return SwiftSource.string(value); } else { - return JSON.stringify(value); + return new SwiftSource(JSON.stringify(value)); } } - return wrap( - "[", - join( + return SwiftSource.wrap( + swift`[`, + SwiftSource.join( args.map(arg => { - return `"${arg.name}": ${expressionFromValue(arg.value)}`; + return swift`${SwiftSource.string(arg.name)}: ${expressionFromValue( + arg.value + )}`; }), ", " ) || ":", - "]" + swift`]` ); } mapExpressionForType( type: GraphQLType, isConditional: boolean = false, - makeExpression: (expression: string) => string, - expression: string, + makeExpression: (expression: SwiftSource) => SwiftSource, + expression: SwiftSource, inputTypeName: string, outputTypeName: string - ): string { + ): SwiftSource { let isOptional; if (isNonNullType(type)) { isOptional = !!isConditional; @@ -281,7 +297,7 @@ export class Helpers { if (isListType(type)) { const elementType = type.ofType; if (isOptional) { - return `${expression}.flatMap { ${makeClosureSignature( + return swift`${expression}.flatMap { ${makeClosureSignature( this.typeNameFromGraphQLType(type, inputTypeName, false), this.typeNameFromGraphQLType(type, outputTypeName, false) )} value.map { ${makeClosureSignature( @@ -291,28 +307,28 @@ export class Helpers { elementType, undefined, makeExpression, - "value", + swift`value`, inputTypeName, outputTypeName )} } }`; } else { - return `${expression}.map { ${makeClosureSignature( + return swift`${expression}.map { ${makeClosureSignature( this.typeNameFromGraphQLType(elementType, inputTypeName), this.typeNameFromGraphQLType(elementType, outputTypeName) )} ${this.mapExpressionForType( elementType, undefined, makeExpression, - "value", + swift`value`, inputTypeName, outputTypeName )} }`; } } else if (isOptional) { - return `${expression}.flatMap { ${makeClosureSignature( + return swift`${expression}.flatMap { ${makeClosureSignature( this.typeNameFromGraphQLType(type, inputTypeName, false), this.typeNameFromGraphQLType(type, outputTypeName, false) - )} ${makeExpression("value")} }`; + )} ${makeExpression(swift`value`)} }`; } else { return makeExpression(expression); } @@ -322,12 +338,12 @@ export class Helpers { function makeClosureSignature( parameterTypeName: string, returnTypeName?: string -) { - let closureSignature = `(value: ${parameterTypeName})`; +): SwiftSource { + let closureSignature = swift`(value: ${parameterTypeName})`; if (returnTypeName) { - closureSignature += ` -> ${returnTypeName}`; + closureSignature.append(swift` -> ${returnTypeName}`); } - closureSignature += " in"; + closureSignature.append(swift` in`); return closureSignature; } diff --git a/packages/apollo-codegen-swift/src/language.ts b/packages/apollo-codegen-swift/src/language.ts index 3df126dacc..20b93b72bb 100644 --- a/packages/apollo-codegen-swift/src/language.ts +++ b/packages/apollo-codegen-swift/src/language.ts @@ -1,6 +1,9 @@ import CodeGenerator from "apollo-codegen-core/lib/utilities/CodeGenerator"; -import { join, wrap } from "apollo-codegen-core/lib/utilities/printing"; +import { + join as _join, + wrap as _wrap +} from "apollo-codegen-core/lib/utilities/printing"; export interface Class { className: string; @@ -28,21 +31,6 @@ export interface Property { description?: string; } -export function escapedString(string: string) { - if (string.includes('"""')) { - // This includes a multi-line string literal, and we may strip out meaningful - // whitespace if we try to strip whitespace. Don't try. - return string.replace(/"/g, '\\"').replace(/\n/g, "\\n"); - } else { - // Strip unnecessary whitespace. - return string - .split(/\n/g) - .map(line => line.trim()) - .map(line => line.replace(/"/g, '\\"')) - .join(" "); - } -} - // prettier-ignore const reservedKeywords = new Set(['associatedtype', 'class', 'deinit', 'enum', 'extension', 'fileprivate', 'func', 'import', 'init', 'inout', 'internal', 'let', 'open', @@ -55,49 +43,266 @@ const reservedKeywords = new Set(['associatedtype', 'class', 'deinit', 'enum', ' 'indirect', 'lazy', 'left', 'mutating', 'none', 'nonmutating', 'optional', 'override', 'postfix', 'precedence', 'prefix', 'Protocol', 'required', 'right', 'set', 'Type', 'unowned', 'weak', 'willSet']); +const reservedMemberKeywords = new Set(["self", "Type", "Protocol"]); /** - * Escapes the identifier if it matches a reserved keyword. - * - * For example, the identifier `Self?` requires escaping or it will match the keyword `Self`. + * A class that represents Swift source. * - * @param identifier The identifier to escape. + * Instances of this type will not undergo escaping when used with the `swift` template tag. */ -export function escapeIdentifierIfNeeded(identifier: string) { - // Swift identifiers use a significantly more complicated definition, but GraphQL names are - // limited to ASCII, so we only have to worry about ASCII strings here. - return identifier.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, match => { - if (reservedKeywords.has(match)) { - return `\`${match}\``; - } else { - return match; +export class SwiftSource { + source: string; + constructor(source: string) { + this.source = source; + } + + /** + * Returns the input wrapped in quotes and escaped appropriately. + * @param string The input string, to be represented as a Swift string. + * @param trim If true, trim the string of whitespace and join into a single line. + * @returns A `SwiftSource` containing the Swift string literal. + */ + static string(string: string, trim: boolean = false): SwiftSource { + if (trim) { + string = string + .split(/\n/g) + .map(line => line.trim()) + .join(" "); } + return new SwiftSource( + // String literal grammar: + // https://docs.swift.org/swift-book/ReferenceManual/LexicalStructure.html#ID417 + // Technically we only need to escape ", \, newline, and carriage return, but as Swift + // defines escapes for NUL and horizontal tab, it produces nicer output to escape those as + // well. + `"${string.replace(/[\0\\\t\n\r"]/g, c => { + switch (c) { + case "\0": + return "\\0"; + case "\t": + return "\\t"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + default: + return `\\${c}`; + } + })}"` + ); + } + + /** + * Escapes the input if it contains a reserved keyword. + * + * For example, the input `Self?` requires escaping or it will match the keyword `Self`. + * + * @param identifier The input containing identifiers to escape. + * @returns The input with all identifiers escaped. + */ + static identifier(input: string): SwiftSource { + // Swift identifiers use a significantly more complicated definition, but GraphQL names are + // limited to ASCII, so we only have to worry about ASCII strings here. + return new SwiftSource( + input.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, (match, offset, fullString) => { + if (reservedKeywords.has(match)) { + // If this keyword comes after a '.' make sure it's also a reservedMemberKeyword. + if ( + offset == 0 || + fullString[offset - 1] !== "." || + reservedMemberKeywords.has(match) + ) { + return `\`${match}\``; + } + } + return match; + }) + ); + } + + /** + * Escapes the input if it begins with a reserved keyword not valid in member position. + * + * Most keywords are valid in member position (e.g. after a period), but a few aren't. This + * method escapes just those keywords not valid in member position, and therefore must only be + * used on input that is guaranteed to come after a dot. + * @param input The input containing identifiers to escape. + * @returns The input with relevant identifiers escaped. + */ + static memberName(input: string): SwiftSource { + return new SwiftSource( + // This behaves nearly identically to `SwiftSource.identifier` except for the logic around + // offset zero, but it's structured a bit differently to optimize for the fact that most + // matched identifiers are at offset zero. + input.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, (match, offset, fullString) => { + if (!reservedMemberKeywords.has(match)) { + // If we're not at offset 0 and not after a period, check the full set. + if ( + offset == 0 || + fullString[offset - 1] === "." || + !reservedKeywords.has(match) + ) { + return match; + } + } + return `\`${match}\``; + }) + ); + } + + /** + * Template tag for producing a `SwiftSource` value without performing escaping. + * + * This is identical to evaluating the template without the tag and passing the result to `new + * SwiftSource(…)`. + */ + static raw( + literals: TemplateStringsArray, + ...placeholders: any[] + ): SwiftSource { + // We can't just evaluate the original template directly, but we can replicate its semantics. + // NB: The semantics of untagged template literals matches String.prototype.concat rather than + // the + operator. Since String.prototype.concat is documented as slower than the + operator, + // we'll just use individual template strings to do the concatenation. + var result = literals[0]; + placeholders.forEach((value, i) => { + result += `${value}${literals[i + 1]}`; + }); + return new SwiftSource(result); + } + + toString(): string { + return this.source; + } + + /** + * Concatenates multiple `SwiftSource`s together. + */ + concat(...sources: SwiftSource[]): SwiftSource { + // Documentation says + is faster than String.concat, so let's use that + return new SwiftSource( + sources.reduce((accum, value) => accum + value.source, this.source) + ); + } + + /** + * Appends one or more `SwiftSource`s to the end of a `SwiftSource`. + * @param sources The `SwiftSource`s to append to the end. + */ + append(...sources: SwiftSource[]) { + for (let value of sources) { + this.source += value.source; + } + } + + /** + * If maybeSource is not null or empty, then wrap with start and end, otherwise return an empty + * string. + * + * This is just a wrapper for `wrap()` from apollo-codegen-core/lib/utilities/printing. + */ + static wrap( + start: SwiftSource, + maybeSource?: SwiftSource, + end?: SwiftSource + ): SwiftSource { + return new SwiftSource( + _wrap( + start.source, + maybeSource !== undefined ? maybeSource.source : undefined, + end !== undefined ? end.source : undefined + ) + ); + } + + /** + * Given maybeArray, return an empty string if it is null or empty, otherwise return all items + * together separated by separator if provided. + * + * This is just a wrapper for `join()` from apollo-codegen-core/lib/utilities/printing. + * + * @param separator The separator to put between elements. This is typed as `string` with the + * expectation that it's generally something like `', '` but if it contains identifiers it should + * be escaped. + */ + static join( + maybeArray?: (SwiftSource | undefined)[], + separator?: string + ): SwiftSource { + return new SwiftSource(_join(maybeArray, separator)); + } +} + +/** + * Template tag for producing a `SwiftSource` value by escaping expressions. + * + * All interpolated expressions will undergo identifier escaping unless the expression value is of + * type `SwiftSource`. If any interpolated expressions are actually intended as string literals, use + * the `SwiftSource.string()` function on the expression. + */ +export function swift( + literals: TemplateStringsArray, + ...placeholders: any[] +): SwiftSource { + let result = literals[0]; + placeholders.forEach((value, i) => { + result += _escape(value); + result += literals[i + 1]; }); + return new SwiftSource(result); } +function _escape(value: any): string { + if (value instanceof SwiftSource) { + return value.source; + } else if (typeof value === "string") { + return SwiftSource.identifier(value).source; + } else if (Array.isArray(value)) { + // I don't know why you'd be interpolating an array, but let's recurse into it. + return value.map(_escape).join(); + } else if (typeof value === "object") { + // use `${…}` instead of toString to preserve string conversion semantics from untagged + // template literals. + return SwiftSource.identifier(`${value}`).source; + } else if (value === undefined) { + return ""; + } else { + // Other primitives don't need to be escaped. + return `${value}`; + } +} + +// Convenience accessors for wrap/join +const { wrap, join } = SwiftSource; + export class SwiftGenerator extends CodeGenerator< Context, - { typeName: string } + { typeName: string }, + SwiftSource > { constructor(context: Context) { super(context); } multilineString(string: string) { - this.printOnNewline(`"${escapedString(string)}"`); + // Disable trimming if the string contains """ as this means we're probably printing an + // operation definition where trimming is destructive. + this.printOnNewline( + SwiftSource.string(string, /* trim */ !string.includes('"""')) + ); } comment(comment?: string) { comment && comment.split("\n").forEach(line => { - this.printOnNewline(`/// ${line.trim()}`); + this.printOnNewline(SwiftSource.raw`/// ${line.trim()}`); }); } commentWithoutTrimming(comment?: string) { comment && comment.split("\n").forEach(line => { - this.printOnNewline(`/// ${line}`); + this.printOnNewline(SwiftSource.raw`/// ${line}`); }); } @@ -111,9 +316,10 @@ export class SwiftGenerator extends CodeGenerator< ? deprecationReason : ""; this.printOnNewline( - `@available(*, deprecated, message: "${escapedString( - deprecationReason - )}")` + swift`@available(*, deprecated, message: ${SwiftSource.string( + deprecationReason, + /* trim */ true + )})` ); } } @@ -121,8 +327,8 @@ export class SwiftGenerator extends CodeGenerator< namespaceDeclaration(namespace: string | undefined, closure: Function) { if (namespace) { this.printNewlineIfNeeded(); - this.printOnNewline(`/// ${namespace} namespace`); - this.printOnNewline(`public enum ${namespace}`); + this.printOnNewline(SwiftSource.raw`/// ${namespace} namespace`); + this.printOnNewline(swift`public enum ${namespace}`); this.pushScope({ typeName: namespace }); this.withinBlock(closure); this.popScope(); @@ -139,8 +345,8 @@ export class SwiftGenerator extends CodeGenerator< ) { if (namespace) { this.printNewlineIfNeeded(); - this.printOnNewline(`/// ${namespace} namespace`); - this.printOnNewline(`public extension ${namespace}`); + this.printOnNewline(SwiftSource.raw`/// ${namespace} namespace`); + this.printOnNewline(swift`public extension ${namespace}`); this.pushScope({ typeName: namespace }); this.withinBlock(closure); this.popScope(); @@ -157,10 +363,24 @@ export class SwiftGenerator extends CodeGenerator< ) { this.printNewlineIfNeeded(); this.printOnNewline( - wrap("", join(modifiers, " "), " ") + - `class ${escapeIdentifierIfNeeded(className)}` + wrap(swift``, new SwiftSource(_join(modifiers, " ")), swift` `).concat( + swift`class ${className}` + ) + ); + this.print( + wrap( + swift`: `, + join( + [ + superClass !== undefined + ? SwiftSource.identifier(superClass) + : undefined, + ...adoptedProtocols.map(SwiftSource.identifier) + ], + ", " + ) + ) ); - this.print(wrap(": ", join([superClass, ...adoptedProtocols], ", "))); this.pushScope({ typeName: className }); this.withinBlock(closure); this.popScope(); @@ -191,12 +411,12 @@ export class SwiftGenerator extends CodeGenerator< adoptedProtocols.includes("GraphQLFragment") && !!namespace && outputIndividualFiles; - const modifier = isRedundant ? "" : "public "; + const modifier = new SwiftSource(isRedundant ? "" : "public "); - this.printOnNewline( - `${modifier}struct ${escapeIdentifierIfNeeded(structName)}` + this.printOnNewline(swift`${modifier}struct ${structName}`); + this.print( + wrap(swift`: `, join(adoptedProtocols.map(SwiftSource.identifier), ", ")) ); - this.print(wrap(": ", join(adoptedProtocols, ", "))); this.pushScope({ typeName: structName }); this.withinBlock(closure); this.popScope(); @@ -204,11 +424,7 @@ export class SwiftGenerator extends CodeGenerator< propertyDeclaration({ propertyName, typeName, description }: Property) { this.comment(description); - this.printOnNewline( - `public var ${escapeIdentifierIfNeeded( - propertyName - )}: ${escapeIdentifierIfNeeded(typeName)}` - ); + this.printOnNewline(swift`public var ${propertyName}: ${typeName}`); } propertyDeclarations(properties: Property[]) { @@ -221,17 +437,25 @@ export class SwiftGenerator extends CodeGenerator< closure: Function ) { this.printNewlineIfNeeded(); - this.printOnNewline(`public protocol ${protocolName}`); - this.print(wrap(": ", join(adoptedProtocols, ", "))); + this.printOnNewline(swift`public protocol ${protocolName}`); + this.print( + wrap( + swift`: `, + join( + adoptedProtocols !== undefined + ? adoptedProtocols.map(SwiftSource.identifier) + : undefined, + ", " + ) + ) + ); this.pushScope({ typeName: protocolName }); this.withinBlock(closure); this.popScope(); } protocolPropertyDeclaration({ propertyName, typeName }: Property) { - this.printOnNewline( - `var ${escapeIdentifierIfNeeded(propertyName)}: ${typeName} { get }` - ); + this.printOnNewline(swift`var ${propertyName}: ${typeName} { get }`); } protocolPropertyDeclarations(properties: Property[]) {