UNPKG

18.4 kBPlain TextView Raw
1import CodeGenerator from "apollo-codegen-core/lib/utilities/CodeGenerator";
2
3import {
4 join as _join,
5 wrap as _wrap
6} from "apollo-codegen-core/lib/utilities/printing";
7
8export interface Class {
9 className: string;
10 modifiers: string[];
11 superClass?: string;
12 adoptedProtocols?: string[];
13}
14
15export interface Struct {
16 structName: string;
17 adoptedProtocols?: string[];
18 description?: string;
19 namespace?: string;
20}
21
22export interface Protocol {
23 protocolName: string;
24 adoptedProtocols?: string[];
25}
26
27export interface Property {
28 propertyName: string;
29 typeName: string;
30 isOptional?: boolean;
31 description?: string;
32}
33
34/**
35 * Swift identifiers that are keywords
36 *
37 * Some of these are context-dependent and can be used as identifiers outside of the relevant
38 * context. As we don't understand context, we will treat them as keywords in all contexts.
39 *
40 * This list does not include keywords that aren't identifiers, such as `#available`.
41 */
42// prettier-ignore
43const reservedKeywords = new Set([
44 // https://docs.swift.org/swift-book/ReferenceManual/LexicalStructure.html#ID413
45 // Keywords used in declarations
46 'associatedtype', 'class', 'deinit', 'enum', 'extension', 'fileprivate',
47 'func', 'import', 'init', 'inout', 'internal', 'let', 'open', 'operator',
48 'private', 'protocol', 'public', 'static', 'struct', 'subscript',
49 'typealias', 'var',
50 // Keywords used in statements
51 'break', 'case', 'continue', 'default', 'defer', 'do', 'else', 'fallthrough',
52 'for', 'guard', 'if', 'in', 'repeat', 'return', 'switch', 'where', 'while',
53 // Keywords used in expressions and types
54 'as', 'Any', 'catch', 'false', 'is', 'nil', 'rethrows', 'super', 'self',
55 'Self', 'throw', 'throws', 'true', 'try',
56 // Keywords used in patterns
57 '_',
58 // Keywords reserved in particular contexts
59 'associativity', 'convenience', 'dynamic', 'didSet', 'final', 'get', 'infix',
60 'indirect', 'lazy', 'left', 'mutating', 'none', 'nonmutating', 'optional',
61 'override', 'postfix', 'precedence', 'prefix', 'Protocol', 'required',
62 'right', 'set', 'Type', 'unowned', 'weak', 'willSet'
63]);
64/**
65 * Swift identifiers that are keywords in member position
66 *
67 * This is the subset of keywords that are known to still be keywords in member position. The
68 * documentation is not explicit about which keywords qualify, but these are the ones that are
69 * known to have meaning in member position.
70 *
71 * We use this to avoid unnecessary escaping with expressions like `.public`.
72 */
73const reservedMemberKeywords = new Set(["self", "Type", "Protocol"]);
74
75/**
76 * A class that represents Swift source.
77 *
78 * Instances of this type will not undergo escaping when used with the `swift` template tag.
79 */
80export class SwiftSource {
81 source: string;
82 constructor(source: string) {
83 this.source = source;
84 }
85
86 /**
87 * Returns the input wrapped in quotes and escaped appropriately.
88 * @param string The input string, to be represented as a Swift string.
89 * @param trim If true, trim the string of whitespace and join into a single line.
90 * @returns A `SwiftSource` containing the Swift string literal.
91 */
92 static string(string: string, trim: boolean = false): SwiftSource {
93 if (trim) {
94 string = string
95 .split(/\n/g)
96 .map(line => line.trim())
97 .join(" ");
98 }
99 return new SwiftSource(
100 // String literal grammar:
101 // https://docs.swift.org/swift-book/ReferenceManual/LexicalStructure.html#ID417
102 // Technically we only need to escape ", \, newline, and carriage return, but as Swift
103 // defines escapes for NUL and horizontal tab, it produces nicer output to escape those as
104 // well.
105 `"${string.replace(/[\0\\\t\n\r"]/g, c => {
106 switch (c) {
107 case "\0":
108 return "\\0";
109 case "\t":
110 return "\\t";
111 case "\n":
112 return "\\n";
113 case "\r":
114 return "\\r";
115 default:
116 return `\\${c}`;
117 }
118 })}"`
119 );
120 }
121
122 /**
123 * Returns the input wrapped in a Swift multiline string with escaping.
124 * @param string The input string, to be represented as a Swift multiline string.
125 * @returns A `SwiftSource` containing the Swift multiline string literal.
126 */
127 static multilineString(string: string): SwiftSource {
128 let rawCount = 0;
129 if (/"""|\\/.test(string)) {
130 // There's a """ (which would need escaping) or a backslash. Let's do a raw string literal instead.
131 // We can't just assume a single # is sufficient as it's possible to include the tokens `"""#` or
132 // `\#n` in a GraphQL multiline string so let's look for those.
133 let re = /"""(#+)|\\(#+)/g;
134 for (let ary = re.exec(string); ary !== null; ary = re.exec(string)) {
135 rawCount = Math.max(
136 rawCount,
137 (ary[1] || "").length,
138 (ary[2] || "").length
139 );
140 }
141 rawCount += 1; // add 1 to get whatever won't collide with the string
142 }
143 const rawToken = "#".repeat(rawCount);
144 return new SwiftSource(
145 `${rawToken}"""\n${string.replace(/[\0\r]/g, c => {
146 // Even in a raw string, we want to escape a couple of characters.
147 // It would be exceedingly weird to have these, but we can still handle them.
148 switch (c) {
149 case "\0":
150 return `\\${rawToken}0`;
151 case "\r":
152 return `\\${rawToken}r`;
153 default:
154 return c;
155 }
156 })}\n"""${rawToken}`
157 );
158 }
159
160 /**
161 * Escapes the input if it contains a reserved keyword.
162 *
163 * For example, the input `Self?` requires escaping or it will match the keyword `Self`.
164 *
165 * @param identifier The input containing identifiers to escape.
166 * @returns The input with all identifiers escaped.
167 */
168 static identifier(input: string): SwiftSource {
169 // Swift identifiers use a significantly more complicated definition, but GraphQL names are
170 // limited to ASCII, so we only have to worry about ASCII strings here.
171 return new SwiftSource(
172 input.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, (match, offset, fullString) => {
173 if (reservedKeywords.has(match)) {
174 // If this keyword comes after a '.' make sure it's also a reservedMemberKeyword.
175 if (
176 offset == 0 ||
177 fullString[offset - 1] !== "." ||
178 reservedMemberKeywords.has(match)
179 ) {
180 return `\`${match}\``;
181 }
182 }
183 return match;
184 })
185 );
186 }
187
188 /**
189 * Escapes the input if it begins with a reserved keyword not valid in member position.
190 *
191 * Most keywords are valid in member position (e.g. after a period), but a few aren't. This
192 * method escapes just those keywords not valid in member position, and therefore must only be
193 * used on input that is guaranteed to come after a dot.
194 * @param input The input containing identifiers to escape.
195 * @returns The input with relevant identifiers escaped.
196 */
197 static memberName(input: string): SwiftSource {
198 return new SwiftSource(
199 // This behaves nearly identically to `SwiftSource.identifier` except for the logic around
200 // offset zero, but it's structured a bit differently to optimize for the fact that most
201 // matched identifiers are at offset zero.
202 input.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, (match, offset, fullString) => {
203 if (!reservedMemberKeywords.has(match)) {
204 // If we're not at offset 0 and not after a period, check the full set.
205 if (
206 offset == 0 ||
207 fullString[offset - 1] === "." ||
208 !reservedKeywords.has(match)
209 ) {
210 return match;
211 }
212 }
213 return `\`${match}\``;
214 })
215 );
216 }
217
218 /**
219 * Returns whether the given name is valid as a method parameter name.
220 *
221 * Certain tokens aren't valid as method parameter names, even when escaped with backticks, as
222 * the compiler interprets the keyword and identifier as the same thing. In particular, `self`
223 * works this way.
224 * @param input The proposed parameter name.
225 * @returns `true` if the name can be used, or `false` if it needs a separate internal parameter
226 * name.
227 */
228 static isValidParameterName(input: string): boolean {
229 // Right now `self` is the only known token that we can't use with escaping.
230 return input !== "self";
231 }
232
233 /**
234 * Template tag for producing a `SwiftSource` value without performing escaping.
235 *
236 * This is identical to evaluating the template without the tag and passing the result to `new
237 * SwiftSource(…)`.
238 */
239 static raw(
240 literals: TemplateStringsArray,
241 ...placeholders: any[]
242 ): SwiftSource {
243 // We can't just evaluate the original template directly, but we can replicate its semantics.
244 // NB: The semantics of untagged template literals matches String.prototype.concat rather than
245 // the + operator. Since String.prototype.concat is documented as slower than the + operator,
246 // we'll just use individual template strings to do the concatenation.
247 var result = literals[0];
248 placeholders.forEach((value, i) => {
249 result += `${value}${literals[i + 1]}`;
250 });
251 return new SwiftSource(result);
252 }
253
254 toString(): string {
255 return this.source;
256 }
257
258 /**
259 * Concatenates multiple `SwiftSource`s together.
260 */
261 concat(...sources: SwiftSource[]): SwiftSource {
262 // Documentation says + is faster than String.concat, so let's use that
263 return new SwiftSource(
264 sources.reduce((accum, value) => accum + value.source, this.source)
265 );
266 }
267
268 /**
269 * Appends one or more `SwiftSource`s to the end of a `SwiftSource`.
270 * @param sources The `SwiftSource`s to append to the end.
271 */
272 append(...sources: SwiftSource[]) {
273 for (let value of sources) {
274 this.source += value.source;
275 }
276 }
277
278 /**
279 * If maybeSource is not undefined or empty, then wrap with start and end, otherwise return
280 * undefined.
281 *
282 * This is largely just a wrapper for `wrap()` from apollo-codegen-core/lib/utilities/printing.
283 */
284 static wrap(
285 start: SwiftSource,
286 maybeSource?: SwiftSource,
287 end?: SwiftSource
288 ): SwiftSource | undefined {
289 const result = _wrap(
290 start.source,
291 maybeSource !== undefined ? maybeSource.source : undefined,
292 end !== undefined ? end.source : undefined
293 );
294 return result ? new SwiftSource(result) : undefined;
295 }
296
297 /**
298 * Given maybeArray, return undefined if it is undefined or empty, otherwise return all items
299 * together separated by separator if provided.
300 *
301 * This is largely just a wrapper for `join()` from apollo-codegen-core/lib/utilities/printing.
302 *
303 * @param separator The separator to put between elements. This is typed as `string` with the
304 * expectation that it's generally something like `', '` but if it contains identifiers it should
305 * be escaped.
306 */
307 static join(
308 maybeArray?: (SwiftSource | undefined)[],
309 separator?: string
310 ): SwiftSource | undefined {
311 const result = _join(maybeArray, separator);
312 return result ? new SwiftSource(result) : undefined;
313 }
314}
315
316/**
317 * Template tag for producing a `SwiftSource` value by escaping expressions.
318 *
319 * All interpolated expressions will undergo identifier escaping unless the expression value is of
320 * type `SwiftSource`. If any interpolated expressions are actually intended as string literals, use
321 * the `SwiftSource.string()` function on the expression.
322 */
323export function swift(
324 literals: TemplateStringsArray,
325 ...placeholders: any[]
326): SwiftSource {
327 let result = literals[0];
328 placeholders.forEach((value, i) => {
329 result += _escape(value);
330 result += literals[i + 1];
331 });
332 return new SwiftSource(result);
333}
334
335function _escape(value: any): string {
336 if (value instanceof SwiftSource) {
337 return value.source;
338 } else if (typeof value === "string") {
339 return SwiftSource.identifier(value).source;
340 } else if (Array.isArray(value)) {
341 // I don't know why you'd be interpolating an array, but let's recurse into it.
342 return value.map(_escape).join();
343 } else if (typeof value === "object") {
344 // use `${…}` instead of toString to preserve string conversion semantics from untagged
345 // template literals.
346 return SwiftSource.identifier(`${value}`).source;
347 } else if (value === undefined) {
348 return "";
349 } else {
350 // Other primitives don't need to be escaped.
351 return `${value}`;
352 }
353}
354
355// Convenience accessors for wrap/join
356const { wrap, join } = SwiftSource;
357
358export class SwiftGenerator<Context> extends CodeGenerator<
359 Context,
360 { typeName: string },
361 SwiftSource
362> {
363 constructor(context: Context) {
364 super(context);
365 }
366
367 /**
368 * Outputs a multi-line string
369 *
370 * @param string - The Multi-lined string to output
371 * @param suppressMultilineStringLiterals - If true, will output the multiline string as a single trimmed
372 * string to save bandwidth.
373 * NOTE: String trimming will be disabled if the string contains a
374 * `"""` sequence as whitespace is significant in GraphQL multiline
375 * strings.
376 */
377 multilineString(string: string, suppressMultilineStringLiterals: Boolean) {
378 if (suppressMultilineStringLiterals) {
379 this.printOnNewline(
380 SwiftSource.string(string, /* trim */ !string.includes('"""'))
381 );
382 } else {
383 SwiftSource.multilineString(string)
384 .source.split("\n")
385 .forEach(line => {
386 this.printOnNewline(new SwiftSource(line));
387 });
388 }
389 }
390
391 comment(comment?: string, trim: Boolean = true) {
392 comment &&
393 comment.split("\n").forEach(line => {
394 this.printOnNewline(SwiftSource.raw`/// ${trim ? line.trim() : line}`);
395 });
396 }
397
398 deprecationAttributes(
399 isDeprecated: boolean | undefined,
400 deprecationReason: string | undefined
401 ) {
402 if (isDeprecated !== undefined && isDeprecated) {
403 deprecationReason =
404 deprecationReason !== undefined && deprecationReason.length > 0
405 ? deprecationReason
406 : "";
407 this.printOnNewline(
408 swift`@available(*, deprecated, message: ${SwiftSource.string(
409 deprecationReason,
410 /* trim */ true
411 )})`
412 );
413 }
414 }
415
416 namespaceDeclaration(namespace: string | undefined, closure: Function) {
417 if (namespace) {
418 this.printNewlineIfNeeded();
419 this.printOnNewline(SwiftSource.raw`/// ${namespace} namespace`);
420 this.printOnNewline(swift`public enum ${namespace}`);
421 this.pushScope({ typeName: namespace });
422 this.withinBlock(closure);
423 this.popScope();
424 } else {
425 if (closure) {
426 closure();
427 }
428 }
429 }
430
431 namespaceExtensionDeclaration(
432 namespace: string | undefined,
433 closure: Function
434 ) {
435 if (namespace) {
436 this.printNewlineIfNeeded();
437 this.printOnNewline(SwiftSource.raw`/// ${namespace} namespace`);
438 this.printOnNewline(swift`public extension ${namespace}`);
439 this.pushScope({ typeName: namespace });
440 this.withinBlock(closure);
441 this.popScope();
442 } else {
443 if (closure) {
444 closure();
445 }
446 }
447 }
448
449 classDeclaration(
450 { className, modifiers, superClass, adoptedProtocols = [] }: Class,
451 closure: Function
452 ) {
453 this.printNewlineIfNeeded();
454 this.printOnNewline(
455 (
456 wrap(swift``, new SwiftSource(_join(modifiers, " ")), swift` `) ||
457 swift``
458 ).concat(swift`class ${className}`)
459 );
460 this.print(
461 wrap(
462 swift`: `,
463 join(
464 [
465 superClass !== undefined
466 ? SwiftSource.identifier(superClass)
467 : undefined,
468 ...adoptedProtocols.map(SwiftSource.identifier)
469 ],
470 ", "
471 )
472 )
473 );
474 this.pushScope({ typeName: className });
475 this.withinBlock(closure);
476 this.popScope();
477 }
478
479 /**
480 * Generates the declaration for a struct
481 *
482 * @param param0 The struct name, description, adoptedProtocols, and namespace to use to generate the struct
483 * @param outputIndividualFiles If this operation is being output as individual files, to help prevent
484 * redundant usages of the `public` modifier in enum extensions.
485 * @param closure The closure to execute which generates the body of the struct.
486 */
487 structDeclaration(
488 {
489 structName,
490 description,
491 adoptedProtocols = [],
492 namespace = undefined
493 }: Struct,
494 outputIndividualFiles: boolean,
495 closure: Function
496 ) {
497 this.printNewlineIfNeeded();
498 this.comment(description);
499
500 const isRedundant =
501 adoptedProtocols.includes("GraphQLFragment") &&
502 !!namespace &&
503 outputIndividualFiles;
504 const modifier = new SwiftSource(isRedundant ? "" : "public ");
505
506 this.printOnNewline(swift`${modifier}struct ${structName}`);
507 this.print(
508 wrap(swift`: `, join(adoptedProtocols.map(SwiftSource.identifier), ", "))
509 );
510 this.pushScope({ typeName: structName });
511 this.withinBlock(closure);
512 this.popScope();
513 }
514
515 propertyDeclaration({ propertyName, typeName, description }: Property) {
516 this.comment(description);
517 this.printOnNewline(swift`public var ${propertyName}: ${typeName}`);
518 }
519
520 propertyDeclarations(properties: Property[]) {
521 if (!properties) return;
522 properties.forEach(property => this.propertyDeclaration(property));
523 }
524
525 protocolDeclaration(
526 { protocolName, adoptedProtocols }: Protocol,
527 closure: Function
528 ) {
529 this.printNewlineIfNeeded();
530 this.printOnNewline(swift`public protocol ${protocolName}`);
531 this.print(
532 wrap(
533 swift`: `,
534 join(
535 adoptedProtocols !== undefined
536 ? adoptedProtocols.map(SwiftSource.identifier)
537 : undefined,
538 ", "
539 )
540 )
541 );
542 this.pushScope({ typeName: protocolName });
543 this.withinBlock(closure);
544 this.popScope();
545 }
546
547 protocolPropertyDeclaration({ propertyName, typeName }: Property) {
548 this.printOnNewline(swift`var ${propertyName}: ${typeName} { get }`);
549 }
550
551 protocolPropertyDeclarations(properties: Property[]) {
552 if (!properties) return;
553 properties.forEach(property => this.protocolPropertyDeclaration(property));
554 }
555}