diff --git a/generators/commons/src/ast/AbstractWriter.ts b/generators/commons/src/ast/AbstractWriter.ts index 7a2bfc53dc7..91a0f230b83 100644 --- a/generators/commons/src/ast/AbstractWriter.ts +++ b/generators/commons/src/ast/AbstractWriter.ts @@ -33,6 +33,14 @@ export class AbstractWriter { this.writeInternal(indentedText); } + /** + * Writes arbitrary text without indentation + * @param text + */ + public writeNoIndent(text: string): void { + this.writeInternal(text); + } + /** * Writes a node * @param node diff --git a/generators/typescript/codegen/src/ast/CodeBlock.ts b/generators/typescript/codegen/src/ast/CodeBlock.ts index 666077c6564..5188dacb210 100644 --- a/generators/typescript/codegen/src/ast/CodeBlock.ts +++ b/generators/typescript/codegen/src/ast/CodeBlock.ts @@ -1,5 +1,6 @@ import { CodeBlock as CommonCodeBlock } from "@fern-api/generator-commons"; -import { AstNode, Writer } from "../typescript"; +import { AstNode } from "./core/AstNode"; +import { Writer } from "./core/Writer"; export declare namespace CodeBlock { /* Write arbitrary code */ diff --git a/generators/typescript/codegen/src/ast/TypeLiteral.ts b/generators/typescript/codegen/src/ast/TypeLiteral.ts new file mode 100644 index 00000000000..8406c9360d8 --- /dev/null +++ b/generators/typescript/codegen/src/ast/TypeLiteral.ts @@ -0,0 +1,169 @@ +import { assertNever } from "@fern-api/core-utils"; +import { AstNode, Writer } from "./core"; +import { Type } from "./Type"; + +type InternalTypeLiteral = Array_ | Boolean_ | Number_ | Object_ | String_ | Tuple; + +interface Array_ { + type: "array"; + valueType: Type; + values: TypeLiteral[]; +} + +interface Boolean_ { + type: "boolean"; + value: boolean; +} + +interface Number_ { + type: "number"; + value: number; +} + +interface Object_ { + type: "object"; + fields: ObjectField[]; +} + +interface ObjectField { + name: string; + valueType: Type; + value: TypeLiteral; +} + +interface String_ { + type: "string"; + value: string; +} + +interface Tuple { + type: "tuple"; + // TODO: In theory this should be a tuple type, not an array of types + valueTypes: Type[]; + values: TypeLiteral[]; +} + +export class TypeLiteral extends AstNode { + private constructor(public readonly internalType: InternalTypeLiteral) { + super(); + } + + public write(writer: Writer): void { + switch (this.internalType.type) { + case "array": { + this.writeIterable({ writer, iterable: this.internalType }); + break; + } + case "boolean": { + writer.write(this.internalType.value.toString()); + break; + } + case "number": { + // N.B. Defaults to decimal; further work needed to support alternatives like hex, binary, octal, etc. + writer.write(this.internalType.value.toString()); + break; + } + case "object": { + this.writeObject({ writer, object: this.internalType }); + break; + } + case "string": { + if (this.internalType.value.includes("\n")) { + this.writeStringWithBackticks({ writer, value: this.internalType.value }); + } else { + writer.write(`"${this.internalType.value.replaceAll('"', '\\"')}"`); + } + break; + } + case "tuple": { + this.writeIterable({ writer, iterable: this.internalType }); + break; + } + default: { + assertNever(this.internalType); + } + } + } + + private writeStringWithBackticks({ writer, value }: { writer: Writer; value: string }): void { + writer.write("`"); + const parts = value.split("\n"); + const head = parts[0] + "\n"; + const tail = parts.slice(1).join("\n"); + writer.write(head.replaceAll("`", "\\`")); + writer.writeNoIndent(tail.replaceAll("`", "\\`")); + writer.write("`"); + } + + private writeIterable({ writer, iterable }: { writer: Writer; iterable: Array_ | Tuple }): void { + if (iterable.values.length === 0) { + // Don't allow "multiline" empty iterables. + writer.write("[]"); + } else { + writer.writeLine("["); + writer.indent(); + for (const value of iterable.values) { + value.write(writer); + writer.writeLine(","); + } + writer.dedent(); + writer.write("]"); + } + } + + private writeObject({ writer, object }: { writer: Writer; object: Object_ }): void { + if (object.fields.length === 0) { + // Don't allow "multiline" empty objects. + writer.write("{}"); + } else { + writer.writeLine("{"); + writer.indent(); + for (const field of object.fields) { + writer.write(`${field.name}: `); + field.value.write(writer); + writer.writeLine(","); + } + writer.dedent(); + writer.write("}"); + } + } + + /* Static factory methods for creating a TypeLiteral */ + public static array({ valueType, values }: { valueType: Type; values: TypeLiteral[] }): TypeLiteral { + return new this({ + type: "array", + valueType, + values + }); + } + + public static boolean(value: boolean): TypeLiteral { + return new this({ type: "boolean", value }); + } + + public static number(value: number): TypeLiteral { + return new this({ type: "number", value }); + } + + public static object(fields: ObjectField[]): TypeLiteral { + return new this({ + type: "object", + fields + }); + } + + public static string(value: string): TypeLiteral { + return new this({ + type: "string", + value + }); + } + + public static tuple({ valueTypes, values }: { valueTypes: Type[]; values: TypeLiteral[] }): TypeLiteral { + return new this({ + type: "tuple", + valueTypes, + values + }); + } +} diff --git a/generators/typescript/codegen/src/ast/__test__/TypeLiteral.test.ts b/generators/typescript/codegen/src/ast/__test__/TypeLiteral.test.ts new file mode 100644 index 00000000000..dd828adda4b --- /dev/null +++ b/generators/typescript/codegen/src/ast/__test__/TypeLiteral.test.ts @@ -0,0 +1,117 @@ +import { ts } from "../.."; + +describe("TypeLiteral", () => { + describe("emptyArrayToString", () => { + it("Should generate an empty array", () => { + const literal = ts.TypeLiteral.array({ + valueType: ts.Type.string(), + values: [] + }); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("arrayOfStringsToString", () => { + it("Should generate an array of strings", () => { + const literal = ts.TypeLiteral.array({ + valueType: ts.Type.string(), + values: [ts.TypeLiteral.string("Hello, World!"), ts.TypeLiteral.string("Goodbye, World!")] + }); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + // N.B. If the array is too short prettier is going to print it on a single line + describe("longArrayOfStringsToString", () => { + it("Should generate a multiline array of strings", () => { + const literal = ts.TypeLiteral.array({ + valueType: ts.Type.string(), + values: [ + ts.TypeLiteral.string("Hello, World!"), + ts.TypeLiteral.string("Goodbye, World!"), + ts.TypeLiteral.string("Hello, World!"), + ts.TypeLiteral.string("Goodbye, World!"), + ts.TypeLiteral.string("Hello, World!"), + ts.TypeLiteral.string("Goodbye, World!"), + ts.TypeLiteral.string("Hello, World!"), + ts.TypeLiteral.string("Goodbye, World!") + ] + }); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("trueBooleanToString", () => { + it("Should generate a true boolean", () => { + const literal = ts.TypeLiteral.boolean(true); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("falseBooleanToString", () => { + it("Should generate a true boolean", () => { + const literal = ts.TypeLiteral.boolean(false); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("numberToString", () => { + it("Should generate a simple number", () => { + const literal = ts.TypeLiteral.number(7); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("stringToString", () => { + it("Should generate a simple string literal", () => { + const literal = ts.TypeLiteral.string("Hello, World!"); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("stringWithDoubleQuotesToString", () => { + it("Should generate a simple string literal with escaped double quotes", () => { + const literal = ts.TypeLiteral.string('"Hello, World!"'); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("manyLinesMultilineStringToString", () => { + it("Should generate a multiline string with backticks", () => { + const literal = ts.TypeLiteral.string(`Hello, +World!`); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("manyLinesMultilineStringWithBackticksToString", () => { + it("Should generate a multiline string with escaped backticks", () => { + const literal = ts.TypeLiteral.string(`\`Hello, +World!\``); + expect(literal.toStringFormatted()).toMatchSnapshot(); + }); + }); + + describe("simpleObjectToString", () => { + it("Should generate a simple object", () => { + const actual = ts.codeblock((writer) => { + writer.write("let myObj = "); + writer.writeNode( + ts.TypeLiteral.object([ + { + name: "name", + valueType: ts.Type.string(), + value: ts.TypeLiteral.string("John Smith") + }, + { + name: "hometown", + valueType: ts.Type.string(), + value: ts.TypeLiteral.string("New York, New York") + } + ]) + ); + }); + expect(actual.toStringFormatted()).toMatchSnapshot(); + }); + }); +}); diff --git a/generators/typescript/codegen/src/ast/__test__/__snapshots__/TypeLiteral.test.ts.snap b/generators/typescript/codegen/src/ast/__test__/__snapshots__/TypeLiteral.test.ts.snap new file mode 100644 index 00000000000..446b99b884b --- /dev/null +++ b/generators/typescript/codegen/src/ast/__test__/__snapshots__/TypeLiteral.test.ts.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TypeLiteral > arrayOfStringsToString > Should generate an array of strings 1`] = ` +"["Hello, World!", "Goodbye, World!"]; +" +`; + +exports[`TypeLiteral > emptyArrayToString > Should generate an empty array 1`] = ` +"[]; +" +`; + +exports[`TypeLiteral > falseBooleanToString > Should generate a true boolean 1`] = ` +"false; +" +`; + +exports[`TypeLiteral > longArrayOfStringsToString > Should generate a multiline array of strings 1`] = ` +"[ + "Hello, World!", + "Goodbye, World!", + "Hello, World!", + "Goodbye, World!", + "Hello, World!", + "Goodbye, World!", + "Hello, World!", + "Goodbye, World!", +]; +" +`; + +exports[`TypeLiteral > manyLinesMultilineStringToString > Should generate a multiline string with backticks 1`] = ` +"\`Hello, +World!\`; +" +`; + +exports[`TypeLiteral > manyLinesMultilineStringWithBackticksToString > Should generate a multiline string with escaped backticks 1`] = ` +"\`\\\`Hello, +World!\\\`\`; +" +`; + +exports[`TypeLiteral > numberToString > Should generate a simple number 1`] = ` +"7; +" +`; + +exports[`TypeLiteral > simpleObjectToString > Should generate a simple object 1`] = ` +"let myObj = { + name: "John Smith", + hometown: "New York, New York", +}; +" +`; + +exports[`TypeLiteral > stringToString > Should generate a simple string literal 1`] = ` +""Hello, World!"; +" +`; + +exports[`TypeLiteral > stringWithDoubleQuotesToString > Should generate a simple string literal with escaped double quotes 1`] = ` +""\\"Hello, World!\\""; +" +`; + +exports[`TypeLiteral > trueBooleanToString > Should generate a true boolean 1`] = ` +"true; +" +`; diff --git a/generators/typescript/codegen/src/ast/core/AstNode.ts b/generators/typescript/codegen/src/ast/core/AstNode.ts index 4b3b2e24e8b..f1bf5a3886f 100644 --- a/generators/typescript/codegen/src/ast/core/AstNode.ts +++ b/generators/typescript/codegen/src/ast/core/AstNode.ts @@ -1,3 +1,18 @@ import { AbstractAstNode } from "@fern-api/generator-commons"; +import { Writer } from "./Writer"; +import * as prettier from "prettier"; -export abstract class AstNode extends AbstractAstNode {} +export abstract class AstNode extends AbstractAstNode { + /** + * Writes the node to a string. + */ + public toString(): string { + const writer = new Writer(); + this.write(writer); + return writer.toString(); + } + + public toStringFormatted(): string { + return prettier.format(this.toString(), { parser: "typescript", tabWidth: 4, printWidth: 120 }); + } +} diff --git a/generators/typescript/codegen/src/ast/index.ts b/generators/typescript/codegen/src/ast/index.ts index c6708fc9b4a..c199126da52 100644 --- a/generators/typescript/codegen/src/ast/index.ts +++ b/generators/typescript/codegen/src/ast/index.ts @@ -1,4 +1,5 @@ -export * from "./core"; +export { AstNode, Writer } from "./core"; export { CodeBlock } from "./CodeBlock"; export { Type } from "./Type"; +export { TypeLiteral } from "./TypeLiteral"; export { Variable } from "./Variable"; diff --git a/generators/typescript/codegen/src/typescript.ts b/generators/typescript/codegen/src/typescript.ts index cd0c595688f..56ea849f476 100644 --- a/generators/typescript/codegen/src/typescript.ts +++ b/generators/typescript/codegen/src/typescript.ts @@ -9,5 +9,5 @@ export function variable(arg: AST.Variable.Args): AST.Variable { } export * from "./ast"; -export { Type as Types } from "./ast"; +export { Type as Types, TypeLiteral } from "./ast"; export * from "./ast/core";