From aa636a3b1642da1a57758d49e0f7f6f79c7e2d01 Mon Sep 17 00:00:00 2001 From: jackofdiamond5 Date: Wed, 3 Apr 2024 17:19:35 +0300 Subject: [PATCH 1/5] feat(core): add new types --- packages/core/types/ISourceManager.ts | 8 +++ packages/core/types/KeyValuePair.ts | 3 + packages/core/types/index.ts | 2 + packages/core/types/types-typescript.ts | 73 +++++++++++++++++++++++++ packages/core/util/GlobalConstants.ts | 5 ++ packages/core/util/index.ts | 15 ++--- 6 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 packages/core/types/ISourceManager.ts create mode 100644 packages/core/types/KeyValuePair.ts create mode 100644 packages/core/types/types-typescript.ts create mode 100644 packages/core/util/GlobalConstants.ts diff --git a/packages/core/types/ISourceManager.ts b/packages/core/types/ISourceManager.ts new file mode 100644 index 000000000..287ce82bc --- /dev/null +++ b/packages/core/types/ISourceManager.ts @@ -0,0 +1,8 @@ +import { VFSLanguageService } from "./VFSLanguageService"; +import ts from "typescript"; + +export interface ISourceManager { + getSourceFile(filePath: string, content: string): ts.SourceFile | undefined; + updateEnvironment(filesMap: Map): void; + get languageService(): VFSLanguageService | undefined; +} diff --git a/packages/core/types/KeyValuePair.ts b/packages/core/types/KeyValuePair.ts new file mode 100644 index 000000000..cbcd18456 --- /dev/null +++ b/packages/core/types/KeyValuePair.ts @@ -0,0 +1,3 @@ +export interface KeyValuePair { + [key: string]: T; +} diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index caf826d87..ba535bff1 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -14,3 +14,5 @@ export * from "./TemplateDependency"; export * from "./enumerations/ControlExtraConfigType"; export * from "./TemplateReplaceDelimiters"; export * from "./FileSystem"; +export * from "./types-typescript"; +export * from './KeyValuePair'; diff --git a/packages/core/types/types-typescript.ts b/packages/core/types/types-typescript.ts new file mode 100644 index 000000000..e27abc3d6 --- /dev/null +++ b/packages/core/types/types-typescript.ts @@ -0,0 +1,73 @@ +import * as ts from 'typescript'; +import { Expression, FormatCodeSettings } from 'typescript'; + +export interface FormattingService { + path: string; + /** Applies formatting to the file after reading it form the `fs`. */ + applyFormatting(sourceFile: ts.SourceFile): string; +} + +export interface FormatSettings extends FormatCodeSettings { + singleQuotes?: boolean; +} + +export interface Identifier { + name: string; + alias?: string; +} + +export interface ImportDeclarationMeta { + identifiers: Identifier | Identifier[]; + moduleName: string; +} + +export interface PropertyAssignment { + name: string; + value: Expression; +} + +/** + * The type of change to apply to the source file. + */ +export enum ChangeType { + NewNode = 'new-node', + NodeUpdate = 'node-update', +} + +/** + * The kind of syntax node to transform in the source file. + */ +export enum SyntaxKind { + PropertyAssignment = 'property-assignment', + ObjectLiteralExpression = 'object-literal-expression', + ArrayLiteralExpression = 'array-literal-expression', + ImportDeclaration = 'import-declaration', + Primitive = 'primitive', + Expression = 'expression', +} + +/** + * A request to change the source file. + */ +export interface ChangeRequest { + /** + * A unique identifier for the change request. It can represent the accumulated values of a node's text or properties. + */ + id: string; + /** + * The type of change to apply to the source file. + */ + type: ChangeType; + /** + * The transformer factory to apply to the source file. + */ + transformerFactory: ts.TransformerFactory; + /** + * The kind of syntax node to transform in the source file. + */ + syntaxKind: SyntaxKind; + /** + * The affected node in the source file. + */ + node: T | ts.NodeArray; +} diff --git a/packages/core/util/GlobalConstants.ts b/packages/core/util/GlobalConstants.ts new file mode 100644 index 000000000..5c2e2b10e --- /dev/null +++ b/packages/core/util/GlobalConstants.ts @@ -0,0 +1,5 @@ +export const ROUTES_VARIABLE_NAME = 'routes'; +export const THEN_IDENTIFIER_NAME = 'then'; +export const IMPORT_IDENTIFIER_NAME = 'import'; +export const SIDE_EFFECTS_IMPORT_TEMPLATE_NAME = 'side_effects_import'; +export const UNDERSCORE_TOKEN = "_"; \ No newline at end of file diff --git a/packages/core/util/index.ts b/packages/core/util/index.ts index 532ba6fbe..b470957d4 100644 --- a/packages/core/util/index.ts +++ b/packages/core/util/index.ts @@ -1,7 +1,8 @@ -export * from "./GoogleAnalytics"; -export * from "./Util"; -export * from "./ProjectConfig"; -export * from "./Schematics"; -export * from "./App"; -export * from "./FileSystem"; -export * from "./Container"; +export * from './GoogleAnalytics'; +export * from './Util'; +export * from './ProjectConfig'; +export * from './Schematics'; +export * from './App'; +export * from './FileSystem'; +export * from './Container'; +export * from './GlobalConstants'; From 82818ddbd3fe22d194b3c7ad072f744eb2a2456f Mon Sep 17 00:00:00 2001 From: jackofdiamond5 Date: Fri, 12 Apr 2024 12:02:24 +0300 Subject: [PATCH 2/5] test(ast-transformer): unit tests --- spec/unit/typescript-ast-transformer.spec.ts | 707 +++++++++++++++++++ 1 file changed, 707 insertions(+) create mode 100644 spec/unit/typescript-ast-transformer.spec.ts diff --git a/spec/unit/typescript-ast-transformer.spec.ts b/spec/unit/typescript-ast-transformer.spec.ts new file mode 100644 index 000000000..400b95568 --- /dev/null +++ b/spec/unit/typescript-ast-transformer.spec.ts @@ -0,0 +1,707 @@ +import { TypeScriptASTTransformer } from '../../packages/core/typescript/TypeScriptASTTransformer'; +import * as ts from 'typescript'; + +const FILE_NAME = 'test-file.ts'; +let FILE_CONTENT = ``; + +describe('TypeScript AST Transformer', () => { + let sourceFile: ts.SourceFile; + let astTransformer: TypeScriptASTTransformer; + + const printer = ts.createPrinter(); + + describe('General', () => { + it('should find a variable declaration by given name & type', () => { + FILE_CONTENT = `const myVar: string = "hello";`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.findVariableDeclaration('myVar', 'string'); + expect(result).toBeDefined(); + }); + + it('should find an exported variable declaration by given name & type', () => { + FILE_CONTENT = `export const myVar: string = "hello";`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.findVariableDeclaration('myVar', 'string'); + expect(result).toBeDefined(); + }); + + it('should create a call expression', () => { + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const typeArg = ts.factory.createKeywordTypeNode( + ts.SyntaxKind.NumberKeyword + ); + const arg = ts.factory.createNumericLiteral('5'); + const callExpression = astTransformer.createCallExpression( + 'x', + 'myGenericFunction', + [typeArg], + [arg] + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + callExpression, + sourceFile + ); + expect(result).toEqual(`x.myGenericFunction(5)`); + }); + + it("should correctly find a node's ancenstor", () => { + FILE_CONTENT = `const myVar: string = "hello";`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const targetChild = astTransformer + .findVariableDeclaration('myVar', 'string') + ?.getChildAt(4)!; + const variableDeclaration = astTransformer.findNodeAncenstor( + targetChild, + ts.isVariableDeclaration + )!; + + expect(ts.isVariableDeclaration(variableDeclaration)).toBeTruthy(); + }); + + it('should find a property assignment in the AST by a given condition', () => { + FILE_CONTENT = `const myObj = { key1: "hello", key2: "world" };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.findPropertyAssignment( + (node) => + ts.isPropertyAssignment(node) && node.name.getText() === 'key1' + ); + expect(result).toBeDefined(); + }); + + it('should find the first matched property assignment by the given condition', () => { + FILE_CONTENT = `const myObj = { key1: "", key2: "world", key3: "hi", children: [{ key1: "", child: "hi" }] };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.findPropertyAssignment( + (node) => + ts.isPropertyAssignment(node) && node.name.getText() === 'key1' + ); + expect(result).toBeDefined(); + expect(result.name.getText()).toEqual('key1'); + expect(result.initializer.getText()).toEqual('""'); + + expect( + ts.isObjectLiteralExpression(result.parent) && + result.parent.properties.length === 4 + ).toBeTruthy('Found incorrect property assignment.'); + }); + + it('should find the last matched property assignment by the given condition', () => { + FILE_CONTENT = `const myObj = { key1: "", key2: "world", key3: "hi", children: [{ key1: "", child: "hi" }] };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.findPropertyAssignment( + (node) => + ts.isPropertyAssignment(node) && node.name.getText() === 'key1', + true + ); + expect(result).toBeDefined(); + expect(result.name.getText()).toEqual('key1'); + expect(result.initializer.getText()).toEqual('""'); + + expect( + ts.isObjectLiteralExpression(result.parent) && + result.parent.properties.length === 2 + ).toBeTruthy('Found incorrect property assignment.'); + }); + + it('should determine correctly if an entity is an IPropertyAssignment', () => { + const propertyAssignment = { + name: 'test', + value: ts.factory.createStringLiteral('value'), + }; + expect( + astTransformer.isPropertyAssignment(propertyAssignment) + ).toBeTruthy(); + + const notPropertyAssignment = { name: 'test', value: 'value' }; + expect( + astTransformer.isPropertyAssignment(notPropertyAssignment) + ).toBeFalsy(); + }); + }); + + describe('Object literals', () => { + beforeEach(() => { + FILE_CONTENT = `const myObj = { key1: "hello", key2: "world" };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + }); + + it('should add member to an object literal', () => { + astTransformer.requestNewMemberInObjectLiteral( + ts.isObjectLiteralExpression, + 'key3', + ts.factory.createStringLiteral('new-value') + ); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myObj = { key1: "hello", key2: "world", key3: "new-value" };\n` + ); + }); + + it('should add member to an object literal with an IPropertyAssignment', () => { + astTransformer.requestNewMemberInObjectLiteral( + ts.isObjectLiteralExpression, + { + name: 'key3', + value: ts.factory.createStringLiteral('new-value'), + } + ); + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myObj = { key1: "hello", key2: "world", key3: "new-value" };\n` + ); + }); + + it('should update an existing member of an object literal', () => { + astTransformer.requestUpdateForObjectLiteralMember( + ts.isObjectLiteralExpression, + { + name: 'key2', + value: ts.factory.createStringLiteral('new-value'), + } + ); + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myObj = { key1: "hello", key2: "new-value" };\n` + ); + }); + + it('should not update a non-existing member of an object literal', () => { + astTransformer.requestUpdateForObjectLiteralMember( + ts.isObjectLiteralExpression, + { + name: 'key3', + value: ts.factory.createStringLiteral('new-value'), + } + ); + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myObj = { key1: "hello", key2: "world" };\n` + ); + }); + + it('should update an object literal if the target node is dynamically added', () => { + astTransformer.requestNewMemberInObjectLiteral( + ts.isObjectLiteralExpression, + 'key3', + ts.factory.createStringLiteral('new-value') + ); + + astTransformer.requestUpdateForObjectLiteralMember( + ts.isObjectLiteralExpression, + { + name: 'key3', + value: ts.factory.createStringLiteral('newer-value'), + } + ); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myObj = { key1: "hello", key2: "world", key3: "newer-value" };\n` + ); + }); + + it('should create an object literal expression', () => { + const newObjectLiteral = astTransformer.createObjectLiteralExpression( + [{ name: 'key3', value: ts.factory.createStringLiteral('new-value') }], + true + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + newObjectLiteral, + sourceFile + ); + expect(result).toEqual(`{\n key3: "new-value"\n}`); + }); + }); + + describe('Array literals', () => { + beforeEach(() => { + FILE_CONTENT = `const myArr = [1, 2, 3];`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + }); + + it('should append element to an array literal', () => { + astTransformer.requestNewMembersInArrayLiteral( + ts.isArrayLiteralExpression, + [ts.factory.createIdentifier('4')] + ); + + const result = astTransformer.finalize(); + expect(result).toEqual(`const myArr = [1, 2, 3, 4];\n`); + }); + + it('should prepend an element to an array literal', () => { + astTransformer.requestNewMembersInArrayLiteral( + ts.isArrayLiteralExpression, + [ts.factory.createIdentifier('4')], + true + ); + + const result = astTransformer.finalize(); + expect(result).toEqual(`const myArr = [4, 1, 2, 3];\n`); + }); + + it('should create an array literal expression with IPropertyAssignment', () => { + const newArrayLiteral = astTransformer.createArrayLiteralExpression([ + { + name: 'key3', + value: ts.factory.createStringLiteral('new-value'), + }, + { + name: 'key4', + value: ts.factory.createNumericLiteral('5'), + }, + ]); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + newArrayLiteral, + sourceFile + ); + + expect(result).toEqual(`[{ key3: "new-value" }, { key4: 5 }]`); + }); + + it('should append elements to an array literal with primitive elements and an anchor element', () => { + astTransformer.requestNewMembersInArrayLiteral( + ts.isArrayLiteralExpression, + [ts.factory.createIdentifier('4')], + true, + ts.factory.createIdentifier('3') + ); + + const result = astTransformer.finalize(); + expect(result).toEqual(`const myArr = [1, 2, 4, 3];\n`); + }); + + it('should append elements to an array literal with object elements and an anchor element', () => { + FILE_CONTENT = `const myArr = [{ test: 1 }, { anchor: 2 }, { other: "another-anchor" }];`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const anchor = { + name: 'anchor', + value: ts.factory.createNumericLiteral('2'), + }; + astTransformer.requestNewMembersInArrayLiteral( + ts.isArrayLiteralExpression, + [ + astTransformer.createObjectLiteralExpression([ + { + name: 'key3', + value: ts.factory.createStringLiteral('new-value'), + }, + ]), + ], + true, + anchor + ); + + const anotherAnchor = { + name: 'other', + value: ts.factory.createStringLiteral('another-anchor'), + }; + astTransformer.requestNewMembersInArrayLiteral( + ts.isArrayLiteralExpression, + [ + astTransformer.createObjectLiteralExpression([ + { + name: 'key4', + value: ts.factory.createStringLiteral('newer-value'), + }, + ]), + ], + true, + anotherAnchor + ); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myArr = [{ test: 1 }, { key3: "new-value" }, { anchor: 2 }, { key4: "newer-value" }, { other: "another-anchor" }];\n` + ); + }); + + it('should create a multilined array literal expression with IPropertyAssignment', () => { + const newArrayLiteral = astTransformer.createArrayLiteralExpression( + [ + { + name: 'key3', + value: ts.factory.createStringLiteral('new-value'), + }, + { + name: 'key4', + value: ts.factory.createNumericLiteral('5'), + }, + ], + true + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + newArrayLiteral, + sourceFile + ); + expect(result).toEqual( + `[\n {\n key3: "new-value"\n },\n {\n key4: 5\n }\n]` + ); + }); + + it('should create an array literal expression', () => { + const newArrayLiteral = astTransformer.createArrayLiteralExpression([ + ts.factory.createStringLiteral('new-value'), + ts.factory.createNumericLiteral('5'), + ]); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + newArrayLiteral, + sourceFile + ); + expect(result).toEqual(`["new-value", 5]`); + }); + + it('should create a multilined array literal expression', () => { + const newArrayLiteral = astTransformer.createArrayLiteralExpression( + [ + ts.factory.createStringLiteral('new-value'), + ts.factory.createNumericLiteral('5'), + ], + true + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + newArrayLiteral, + sourceFile + ); + expect(result).toEqual(`[\n "new-value",\n 5\n]`); + }); + + it('should find an element in an array literal by a given condition', () => { + const result = astTransformer.findElementInArrayLiteral( + (node) => ts.isNumericLiteral(node) && node.text === '2' + ); + expect(result).toBeDefined(); + }); + }); + + describe('Imports', () => { + describe('Creating imports', () => { + beforeEach(() => { + FILE_CONTENT = ``; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + }); + + it('should create an import declaration', () => { + const importDeclaration = astTransformer.createImportDeclaration({ + identifiers: { name: 'mock' }, + moduleName: 'module', + }); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + importDeclaration, + sourceFile + ); + expect(result).toEqual(`import { mock } from "module";`); + }); + + it('should create an import declaration with an alias', () => { + const importDeclaration = astTransformer.createImportDeclaration({ + identifiers: { name: 'SomeImport', alias: 'mock' }, + moduleName: 'module', + }); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + importDeclaration, + sourceFile + ); + expect(result).toEqual(`import { SomeImport as mock } from "module";`); + }); + + it('should create a default import declaration', () => { + const importDeclaration = astTransformer.createImportDeclaration( + { identifiers: { name: 'SomeMock' }, moduleName: 'module' }, + true + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + importDeclaration, + sourceFile + ); + expect(result).toEqual(`import SomeMock from "module";`); + }); + + it('should default to the first identifier for a default import declaration when multiple identifiers are passed in', () => { + const importDeclaration = astTransformer.createImportDeclaration( + { + identifiers: [{ name: 'SomeMock' }, { name: 'AnotherMock' }], + moduleName: 'module', + }, + true + ); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + importDeclaration, + sourceFile + ); + expect(result).toEqual(`import SomeMock from "module";`); + }); + + // TODO: maybe? + xit('should create an import declaration with a namespace import', () => { + const importDeclaration = astTransformer.createImportDeclaration({ + identifiers: { name: '*', alias: 'mock' }, + moduleName: 'another-module', + }); + + const result = printer.printNode( + ts.EmitHint.Unspecified, + importDeclaration, + sourceFile + ); + expect(result).toEqual(`import * as mock from "another-module";`); + }); + }); + + describe('Adding and verifying imports', () => { + beforeEach(() => { + FILE_CONTENT = `import { mock } from "module";`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptASTTransformer(sourceFile); + }); + describe('Adding imports', () => { + it('should add an import declaration', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: { name: 'AnotherMock' }, + moduleName: 'another/module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock } from "module";\nimport { AnotherMock } from "another/module";\n` + ); + }); + + it('should add an import declaration with an alias', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: { name: 'AnotherMock', alias: 'anotherMock' }, + moduleName: 'another/module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock } from "module";\nimport { AnotherMock as anotherMock } from "another/module";\n` + ); + }); + + it('should add an import declaration as a default import', () => { + astTransformer.requestNewImportDeclaration( + { + identifiers: { name: 'AnotherMock' }, + moduleName: 'another/module', + }, + true + ); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock } from "module";\nimport AnotherMock from "another/module";\n` + ); + }); + + it('should add an import declaration with an existing identifier if it is aliased and is from the same module', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: { name: 'mock', alias: 'anotherMock' }, + moduleName: 'module', + }); + + // this is a confusing edge case that results in valid TypeScript as technically no identifier names collide. + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock, mock as anotherMock } from "module";\n` + ); + }); + + it('should add an import declaration with an existing identifier if it is aliased and is from a different module', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: { name: 'mock', alias: 'anotherMock' }, + moduleName: 'another/module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock } from "module";\nimport { mock as anotherMock } from "another/module";\n` + ); + }); + + it('should add identifier to an existing import declaration', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: { name: 'AnotherMock' }, + moduleName: 'module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock, AnotherMock } from "module";\n` + ); + }); + + it('should add an import declaration with multiple identifiers', () => { + astTransformer.requestNewImportDeclaration({ + identifiers: [ + { name: 'AnotherMock' }, + { name: 'YetAnotherMock', alias: 'yetAnotherMock' }, + ], + moduleName: 'module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock, AnotherMock, YetAnotherMock as yetAnotherMock } from "module";\n` + ); + }); + }); + + describe('Imports collision detection', () => { + it('should detect collisions between existing imports', () => { + const identifier = { name: 'mock' }; + expect(astTransformer.importDeclarationCollides(identifier)).toBe( + true + ); + astTransformer.requestNewImportDeclaration({ + identifiers: identifier, + moduleName: 'module', + }); + + const result = astTransformer.finalize(); + expect(result).toEqual(`import { mock, mock } from "module";\n`); + }); + + it('should detect collisions between import declarations with the same alias', () => { + let identifier = { name: 'newMock', alias: 'aliasedMock' }; + expect( + TypeScriptASTTransformer.checkForCollidingImports(sourceFile, [ + identifier, + ]) + ).toBe(false); + + astTransformer.requestNewImportDeclaration({ + identifiers: identifier, + moduleName: 'another/module', + }); + sourceFile = astTransformer.applyChanges(); + expect( + TypeScriptASTTransformer.checkForCollidingImports(sourceFile, [ + identifier, + ]) + ).toBe(true); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + identifier = { name: 'someMock', alias: 'aliasedMock' }; + astTransformer.requestNewImportDeclaration({ + identifiers: identifier, + moduleName: 'yet/another/module', + }); + sourceFile = astTransformer.applyChanges(); + expect( + TypeScriptASTTransformer.checkForCollidingImports(sourceFile, [ + identifier, + ]) + ).toBe(true); + astTransformer = new TypeScriptASTTransformer(sourceFile); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `import { mock } from "module";\nimport { newMock as aliasedMock } from "another/module";\n` + + `import { someMock as aliasedMock } from "yet/another/module";\n` + ); + }); + }); + }); + }); +}); From 9854847e32c718d82013e1fc8b58d9b06700b819 Mon Sep 17 00:00:00 2001 From: jackofdiamond5 Date: Wed, 3 Apr 2024 17:19:45 +0300 Subject: [PATCH 3/5] feat(core): add TypeScriptASTTransformer --- .../typescript/TypeScriptASTTransformer.ts | 1089 +++++++++++++++++ ....ts => TypeScript-AST-Transformer-spec.ts} | 2 +- 2 files changed, 1090 insertions(+), 1 deletion(-) create mode 100644 packages/core/typescript/TypeScriptASTTransformer.ts rename spec/unit/{typescript-ast-transformer.spec.ts => TypeScript-AST-Transformer-spec.ts} (99%) diff --git a/packages/core/typescript/TypeScriptASTTransformer.ts b/packages/core/typescript/TypeScriptASTTransformer.ts new file mode 100644 index 000000000..45f3d0377 --- /dev/null +++ b/packages/core/typescript/TypeScriptASTTransformer.ts @@ -0,0 +1,1089 @@ +import * as ts from 'typescript'; +import * as crypto from 'crypto'; +import { + KeyValuePair, + FormattingService, + PropertyAssignment, + Identifier, + ImportDeclarationMeta, + FormatSettings, + ChangeRequest, + ChangeType, + SyntaxKind, +} from '../types'; +import { SIDE_EFFECTS_IMPORT_TEMPLATE_NAME, UNDERSCORE_TOKEN } from '../util'; +import { TypeScriptFormattingService } from './TypeScriptFormattingService'; + +export class TypeScriptASTTransformer { + private _printer: ts.Printer | undefined; + private _flatNodeRelations: Map; + private _defaultCompilerOptions: ts.CompilerOptions = { + pretty: true, + }; + + /** + * Create a new source update instance for the given source file. + * @param sourceFile The source file to update. + * @param printerOptions Options to use when printing the source file. + * @param customCompilerOptions Custom compiler options to use when transforming the source file. + * @param formatSettings Custom formatting settings to apply. If provided, a {@link TypeScriptFormattingService} will be initialized. + */ + constructor( + public readonly sourceFile: ts.SourceFile, + protected readonly printerOptions?: ts.PrinterOptions, + protected readonly customCompilerOptions?: ts.CompilerOptions, + readonly formatSettings?: FormatSettings + ) { + if (formatSettings) { + this.formatter = new TypeScriptFormattingService( + sourceFile.fileName, + formatSettings + ); + } + + this._flatNodeRelations = this.createNodeRelationsMap(this.sourceFile); + } + + /** Map of all transformations to apply to the source file. */ + public readonly transformations = new Map>(); + + /** The formatting service to use when printing the source file. */ + public formatter: FormattingService; + + /** A map of nodes with their parents. */ + public get flatNodeRelations() { + return this._flatNodeRelations; + } + + /** + * The printer instance to use to print the source file after modifications. + */ + public get printer(): ts.Printer { + if (!this._printer) { + this._printer = ts.createPrinter(this.printerOptions); + } + + return this._printer; + } + + /** + * The compiler options to use when transforming the source file. + */ + public get compilerOptions(): ts.CompilerOptions { + return Object.assign( + {}, + this._defaultCompilerOptions, + this.customCompilerOptions + ); + } + + /** + * Checks if the given import declaration identifiers or aliases would collide with an existing one's. + * @param sourceFile The source file to check for collisions. + * @param identifiers The identifiers to check for collisions. + * @param moduleName The module that the import is for, used for side effects imports. + */ + public static checkForCollidingImports( + sourceFile: ts.SourceFile, + identifiers: Identifier[], + moduleName?: string + ): boolean { + const transformer = new TypeScriptASTTransformer(sourceFile); + return identifiers.some((identifier) => + transformer.importDeclarationCollides(identifier, moduleName) + ); + } + + /** + * Looks up a property assignment in the AST. + * @param visitCondition The condition by which the property assignment is found. + * @param lastMatch Whether to return the last match found. If not set, the first match will be returned. + */ + public findPropertyAssignment( + visitCondition: (node: ts.PropertyAssignment) => boolean, + lastMatch: boolean = false + ): ts.PropertyAssignment | undefined { + let propertyAssignment: ts.PropertyAssignment | undefined; + const visitor: ts.Visitor = (node) => { + if (ts.isPropertyAssignment(node) && visitCondition(node)) { + return (propertyAssignment = node); + } + if (!propertyAssignment || lastMatch) { + return ts.visitEachChild(node, visitor, undefined); + } + return undefined; + }; + + ts.visitNode(this.sourceFile, visitor, ts.isPropertyAssignment); + return propertyAssignment; + } + + /** + * Looks up an object literal expression in the AST with a specific property assignment. + * @param name The name of the property to look for. + * @param value The value of the property to look for. + */ + public findObjectLiteralWithProperty( + name: string, + value: string + ): ts.ObjectLiteralExpression | undefined { + let objectLiteral: ts.ObjectLiteralExpression | undefined; + const visitor: ts.Visitor = (node) => { + if (ts.isObjectLiteralExpression(node)) { + const property = node.properties.find((property) => { + if (ts.isPropertyAssignment(property)) { + return ( + ts.isIdentifier(property.name) && + property.name.text === name && + ts.isLiteralExpression(property.initializer) && + property.initializer.text === value + ); + } + }); + if (property) { + return (objectLiteral = node); + } + return ts.visitEachChild(node, visitor, undefined); + } + }; + ts.visitNode(this.sourceFile, visitor, ts.isObjectLiteralExpression); + return objectLiteral; + } + + /** + * Searches the AST for a variable declaration with the given name and type. + * @param name The name of the variable to look for. + * @param type The type of the variable to look for. + * @returns The variable declaration if found, otherwise `undefined`. + */ + public findVariableDeclaration( + name: string, + type: string + ): ts.VariableDeclaration | undefined { + let declaration; + ts.forEachChild(this.sourceFile, (node) => { + if ( + ts.isVariableDeclaration(node) && + node.name.getText() === name && + node.type?.getText() === type + ) { + declaration = node; + } else if (ts.isVariableStatement(node)) { + declaration = node.declarationList.declarations.find( + (declaration) => + declaration.name.getText() === name && + declaration.type?.getText() === type + ); + } + // handle variable declaration lists (ts.isVariableDeclarationList)? + // const a = 5, b = 6...; + }); + + return declaration; + } + + /** + * Traverses the {@link flatNodeRelations} up to find a node that satisfies the given condition. + * @param node The starting point of the search. + * @param condition The condition to satisfy. + * @returns The node's ancestor that satisfies the condition, `undefined` if none is found. + */ + public findNodeAncestor( + node: ts.Node, + condition: (node: ts.Node) => boolean + ): ts.Node | undefined { + if (condition(node)) { + return node; + } + + const parent = this.flatNodeRelations.get(node); + if (parent) { + return this.findNodeAncestor(parent, condition); + } + + // no parent node satisfies the condition + return undefined; + } + + /** + * Creates a request that will resolve during {@link finalize} for a new property assignment in an object literal expression. + * @param visitCondition The condition by which the object literal expression is found. + * @param propertyAssignment The property that will be added. + */ + public requestNewMemberInObjectLiteral( + visitCondition: (node: ts.ObjectLiteralExpression) => boolean, + propertyAssignment: PropertyAssignment + ): void; + /** + * Creates a request that will resolve during {@link finalize} for a new property assignment in an object literal expression. + * @param visitCondition The condition by which the object literal expression is found. + * @param propertyName The name of the property that will be added. + * @param propertyValue The value of the property that will be added. + */ + public requestNewMemberInObjectLiteral( + visitCondition: (node: ts.ObjectLiteralExpression) => boolean, + propertyName: string, + propertyValue: ts.Expression + ): void; + public requestNewMemberInObjectLiteral( + visitCondition: (node: ts.ObjectLiteralExpression) => boolean, + propertyNameOrAssignment: string | PropertyAssignment, + propertyValue?: ts.Expression + ): void { + let newProperty: ts.PropertyAssignment; + if (propertyNameOrAssignment instanceof Object) { + newProperty = ts.factory.createPropertyAssignment( + propertyNameOrAssignment.name, + propertyNameOrAssignment.value + ); + } else if (propertyValue) { + newProperty = ts.factory.createPropertyAssignment( + ts.factory.createIdentifier(propertyNameOrAssignment as string), + propertyValue + ); + } else { + throw new Error('Must provide property value.'); + } + + const transformer: ts.TransformerFactory = < + T extends ts.Node + >( + context: ts.TransformationContext + ) => { + return (rootNode: T) => { + const visitor = (node: ts.Node): ts.VisitResult => { + if (ts.isObjectLiteralExpression(node) && visitCondition(node)) { + return context.factory.updateObjectLiteralExpression(node, [ + ...node.properties, + newProperty, + ]); + } + return ts.visitEachChild(node, visitor, context); + }; + return ts.visitNode(rootNode, visitor, ts.isSourceFile); + }; + }; + + let id = ''; + if ( + ts.isIdentifier(newProperty.name) || + ts.isLiteralExpression(newProperty.name) + ) { + id = newProperty.name.text; + } + this.requestChange( + id, + ChangeType.NewNode, + transformer, + SyntaxKind.PropertyAssignment, + newProperty + ); + } + + /** + * Creates a request that will resolve during {@link finalize} for a new property assignment that has a JSX value. + * The member is added in an object literal expression. + * @param visitCondition The condition by which the object literal expression is found. + * @param propertyName The name of the property that will be added. + * @param propertyValue The value of the property that will be added. + * @param jsxAttributes The JSX attributes to add to the JSX element. + * + * @remarks Creates a property assignment of the form `{ propertyName: }` in the object literal. + */ + public requestJsxMemberInObjectLiteral( + visitCondition: (node: ts.ObjectLiteralExpression) => boolean, + propertyName: string, + propertyValue: string, + jsxAttributes?: ts.JsxAttributes + ): void { + const jsxElement = ts.factory.createJsxSelfClosingElement( + ts.factory.createIdentifier(propertyValue), + undefined, // type arguments + jsxAttributes + ); + + this.requestNewMemberInObjectLiteral( + visitCondition, + propertyName, + jsxElement + ); + } + + /** + * Creates a request that will resolve during {@link finalize } for an update to the value of a member in an object literal expression. + * @param visitCondition The condition by which the object literal expression is found. + * @param targetMember The member that will be updated. The value should be the new value to set. + * @returns The mutated AST. + */ + public requestUpdateForObjectLiteralMember( + visitCondition: (node: ts.ObjectLiteralExpression) => boolean, + targetMember: PropertyAssignment + ): void { + const transformer: ts.TransformerFactory = < + T extends ts.Node + >( + context: ts.TransformationContext + ) => { + return (rootNode: T) => { + const visitor = (node: ts.Node): ts.VisitResult => { + if (ts.isObjectLiteralExpression(node) && visitCondition(node)) { + const newProperties = node.properties.map((property) => { + const isPropertyAssignment = ts.isPropertyAssignment(property); + if ( + isPropertyAssignment && + ts.isIdentifier(property.name) && + property.name.text === targetMember.name + ) { + return context.factory.updatePropertyAssignment( + property, + property.name, + targetMember.value + ); + } + return property; + }); + + return context.factory.updateObjectLiteralExpression( + node, + newProperties + ); + } + return ts.visitEachChild(node, visitor, context); + }; + return ts.visitNode(rootNode, visitor, ts.isSourceFile); + }; + }; + + this.requestChange( + targetMember.name, + ChangeType.NodeUpdate, + transformer, + SyntaxKind.PropertyAssignment, + ts.factory.createPropertyAssignment(targetMember.name, targetMember.value) + ); + } + + /** + * Creates a new object literal expression with the given properties. + * @param properties The properties to add to the object literal. + * @param multiline Whether the object literal should be multiline. + * @param transform A function to transform the value of the property. + * @remarks A `transform` function should be provided if the `properties` are of type `KeyValuePair`. + */ + public createObjectLiteralExpression( + properties: PropertyAssignment[] | KeyValuePair[], + multiline: boolean = false, + transform?: (value: string) => ts.Expression + ): ts.ObjectLiteralExpression { + let propertyAssignments: ts.ObjectLiteralElementLike[] = []; + if (properties.every(this.isPropertyAssignment)) { + propertyAssignments = properties.map((property) => + ts.factory.createPropertyAssignment(property.name, property.value) + ); + } else { + for (const property of properties) { + propertyAssignments.push( + ...this.mapKeyValuePairToObjectLiteral(property, (value) => + transform + ? transform(value) + : ts.factory.createStringLiteral( + value, + this.formatSettings?.singleQuotes + ) + ) + ); + } + } + + return ts.factory.createObjectLiteralExpression( + propertyAssignments, + multiline + ); + } + + /** + * Creates a request that will resolve during {@link finalize} which adds a new element to an array literal expression. + * @param visitCondition The condition by which the array literal expression is found. + * @param elements The elements that will be added to the array literal. + * @param prepend If the elements should be added at the beginning of the array. + * @anchorElement The element to anchor the new elements to. + * @remarks The `anchorElement` must match the type of the elements in the collection. + */ + public requestNewMembersInArrayLiteral( + visitCondition: (node: ts.ArrayLiteralExpression) => boolean, + elements: ts.Expression[], + prepend?: boolean, + anchorElement?: ts.Expression | PropertyAssignment + ): void; + /** + * Creates a request that will resolve during {@link finalize} which adds a new element to an array literal expression. + * @param visitCondition The condition by which the array literal expression is found. + * @param elements The elements that will be added to the array literal + * @prepend If the elements should be added at the beginning of the array. + * @anchorElement The element to anchor the new elements to. + * @remarks The `anchorElement` must match the type of the elements in the collection. + */ + public requestNewMembersInArrayLiteral( + visitCondition: (node: ts.ArrayLiteralExpression) => boolean, + elements: PropertyAssignment[], + prepend?: boolean, + anchorElement?: ts.Expression | PropertyAssignment + ): void; + public requestNewMembersInArrayLiteral( + visitCondition: (node: ts.ArrayLiteralExpression) => boolean, + expressionOrPropertyAssignment: ts.Expression[] | PropertyAssignment[], + prepend: boolean = false, + anchorElement?: ts.StringLiteral | ts.NumericLiteral | PropertyAssignment + ): void { + let elements: ts.Expression[] | PropertyAssignment[]; + const isExpression = expressionOrPropertyAssignment.every((e) => + ts.isExpression(e as ts.Node) + ); + if (isExpression) { + elements = expressionOrPropertyAssignment as ts.Expression[]; + } else { + elements = (expressionOrPropertyAssignment as PropertyAssignment[]).map( + (property) => this.createObjectLiteralExpression([property]) + ); + } + const transformer: ts.TransformerFactory = < + T extends ts.Node + >( + context: ts.TransformationContext + ) => { + return (rootNode: T) => { + const visitor = (node: ts.Node): ts.VisitResult => { + let anchor: ts.Expression | undefined; + if (ts.isArrayLiteralExpression(node) && visitCondition(node)) { + if (anchorElement) { + anchor = Array.from(node.elements).find((e) => { + if (ts.isStringLiteral(e) || ts.isNumericLiteral(e)) { + // make sure the entry is a string or numeric literal + // and that its text matches the anchor element's text + return ( + e.text === + (anchorElement as ts.StringLiteral | ts.NumericLiteral).text + ); + } else if ( + this.isPropertyAssignment(anchorElement) && + ts.isObjectLiteralExpression(e) + ) { + // make sure the entry is a property assignment + // and that its name and value match the anchor element's + return e.properties.some( + (p) => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === anchorElement.name && + ((ts.isStringLiteral(p.initializer) && + ts.isStringLiteral(anchorElement.value)) || + (ts.isNumericLiteral(p.initializer) && + ts.isNumericLiteral(anchorElement.value))) && + p.initializer.text === anchorElement.value.text + ); + } + return false; + }); + } + + if (anchor) { + let structure!: ts.Expression[]; + if (prepend) { + structure = node.elements + .slice(0, node.elements.indexOf(anchor)) + .concat(elements) + .concat(node.elements.slice(node.elements.indexOf(anchor))); + } else { + structure = node.elements + .slice(0, node.elements.indexOf(anchor) + 1) + .concat(elements) + .concat( + node.elements.slice(node.elements.indexOf(anchor) + 1) + ); + } + + return context.factory.updateArrayLiteralExpression( + node, + structure + ); + } + + if (prepend) { + return context.factory.updateArrayLiteralExpression(node, [ + ...elements, + ...node.elements, + ]); + } + return context.factory.updateArrayLiteralExpression(node, [ + ...node.elements, + ...elements, + ]); + } + return ts.visitEachChild(node, visitor, context); + }; + return ts.visitNode(rootNode, visitor, ts.isSourceFile); + }; + }; + + const id = elements + .map((e) => { + if (ts.isObjectLiteralExpression(e)) { + return e.properties + .map((p) => (ts.isIdentifier(p.name) ? p.name.text : '')) + .join(UNDERSCORE_TOKEN); + } + + if (ts.isIdentifier(e)) { + return e.text; + } + + return ts.isLiteralExpression(e) ? e.text : ''; + }) + .join(UNDERSCORE_TOKEN); + this.requestChange( + id, + ChangeType.NewNode, + transformer, + isExpression + ? SyntaxKind.Expression + : SyntaxKind.PropertyAssignment, + ts.factory.createNodeArray(elements) + ); + } + + /** + * Creates an array literal expression with the given elements. + * @param elements The elements to include in the array literal. + * @param multiline Whether the array literal should be multiline. + */ + public createArrayLiteralExpression( + elements: ts.Expression[], + multiline?: boolean + ): ts.ArrayLiteralExpression; + public createArrayLiteralExpression( + elements: PropertyAssignment[], + multiline?: boolean + ): ts.ArrayLiteralExpression; + public createArrayLiteralExpression( + elementsOrProperties: ts.Expression[] | PropertyAssignment[], + multiline: boolean = false + ): ts.ArrayLiteralExpression { + if ( + elementsOrProperties.every((element) => + ts.isExpression(element as ts.Node) + ) + ) { + return ts.factory.createArrayLiteralExpression( + elementsOrProperties as ts.Expression[], + multiline + ); + } + + const propertyAssignments = ( + elementsOrProperties as PropertyAssignment[] + ).map((property) => + this.createObjectLiteralExpression([property], multiline) + ); + return ts.factory.createArrayLiteralExpression( + propertyAssignments, + multiline + ); + } + + /** + * Finds an element in an array literal expression that satisfies the given condition. + * @param visitCondition The condition by which the element is found. + */ + public findElementInArrayLiteral( + visitCondition: (node: ts.Expression) => boolean + ): ts.Expression | undefined { + let target: ts.Expression | undefined; + const visitor: ts.Visitor = (node) => { + if (ts.isArrayLiteralExpression(node)) { + node.elements.find((element) => { + if (visitCondition(element)) { + target = element; + } + }); + if (target) { + return target; + } + } + + return ts.visitEachChild(node, visitor, undefined); + }; + + ts.visitNode(this.sourceFile, visitor, ts.isExpression); + return target; + } + + /** + * Creates a `ts.CallExpression` for an identifier with a method call. + * @param identifierName Identifier text. + * @param call Method to call, creating `.()`. + * @param typeArgs Type arguments for the call, translates to type arguments for generic methods - `myMethod`. + * @param args Arguments for the call, translates to arguments for the method - `myMethod(arg1, arg2, ...)`. + * @remarks Create `typeArgs` with methods like `ts.factory.createXXXTypeNode`. + * + * ``` + * const typeArg = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + * const arg = ts.factory.createNumericLiteral('5'); + * const callExpression = astTransformer.createCallExpression( + * 'x', + * 'myGenericFunction', + * [typeArg], + * [arg] + * ); + * + * // This would create the function call + * x.myGenericFunction(5) + * ``` + */ + public createCallExpression( + identifierName: string, + call: string, + typeArgs?: ts.TypeNode[], + args?: ts.Expression[] + ): ts.CallExpression { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier(identifierName), + call + ), + typeArgs, + args + ); + } + + /** + * Creates a node for a named import declaration. + * @param importDeclarationMeta Metadata for the new import declaration. + * @param isDefault Whether the import is a default import. + * @param isSideEffects Whether the import is a side effects import. + * @returns A named import declaration of the form `import { MyClass } from "my-module"`. + * @remarks If `isDefault` is `true`, the first element of `identifiers` will be used and + * the import will be a default import of the form `import MyClass from "my-module"`. + * @remarks + * If `isSideEffects` is `true`, all other options are ignored + * and the import will be a side effects import of the form `import "my-module"`. + * @reference {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#description|MDN} + */ + public createImportDeclaration( + importDeclarationMeta: ImportDeclarationMeta, + isDefault: boolean = false, + isSideEffects: boolean = false + ): ts.ImportDeclaration { + if (isSideEffects) { + // create a side effects declaration of the form `import "my-module"` + return ts.factory.createImportDeclaration( + undefined, // modifiers + undefined, // import clause + ts.factory.createStringLiteral( + importDeclarationMeta.moduleName, + this.formatSettings?.singleQuotes + ) // module specifier + ); + } + + const identifiers = Array.isArray(importDeclarationMeta.identifiers) + ? importDeclarationMeta.identifiers + : [importDeclarationMeta.identifiers]; + let importClause: ts.ImportClause; + // isTypeOnly on the import clause is set to false because we don't import types atm + // might change it later if we need sth like - import type { X } from "module" + // TODO: consider adding functionality for namespaced imports of the form - import * as X from "module" + if (isDefault) { + importClause = ts.factory.createImportClause( + false, // is type only + ts.factory.createIdentifier(identifiers[0].name) as ts.Identifier, // name - import X from "module" + undefined // named bindings + ); + } else { + const namedImport = ts.factory.createNamedImports( + identifiers.map(this.createImportSpecifierWithOptionalAlias) + ); + importClause = ts.factory.createImportClause( + false, // is type only + undefined, // name + namedImport // named bindings - import { X, Y... } from "module" + ); + } + + const importDeclaration = ts.factory.createImportDeclaration( + undefined, // modifiers + importClause, + ts.factory.createStringLiteral( + importDeclarationMeta.moduleName, + this.formatSettings?.singleQuotes + ) // module specifier + ); + + return importDeclaration; + } + + /** + * Checks if an import declaration's identifier or alias would collide with an existing one. + * @param identifier The identifier to check for collisions. + * @param moduleName The module that the import is for, used for side effects imports. + * @param isSideEffects If the import is strictly a side effects import. + * @remarks Dynamically added import declarations are ignored. + */ + public importDeclarationCollides( + identifier: Identifier, + moduleName?: string, + isSideEffects: boolean = false + ): boolean { + // identifiers are gathered from all import declarations + // and are kept as separate entries in the map + const allImportedIdentifiers = this.gatherImportDeclarations([ + ...this.sourceFile.statements, + ]); + + if (isSideEffects && moduleName) { + return Array.from(allImportedIdentifiers.values()).some( + (importStatement) => importStatement.moduleName === moduleName + ); + } + + return Array.from(allImportedIdentifiers.values()).some( + (importStatement) => { + let collides = false; + if (Array.isArray(importStatement.identifiers)) { + return collides; + } + + if (importStatement.identifiers.name === identifier.name) { + collides = true; + } + + if (importStatement.identifiers.alias && identifier.alias) { + return importStatement.identifiers.alias === identifier.alias; + } + + return collides; + } + ); + } + + /** + * Creates a request that will resolve during {@link finalize} which adds an import declaration to the source file. + * @param importDeclarationMeta Metadata for the new import declaration. + * @param isDefault Whether the import is a default import. + * @remarks If `isDefault` is `true`, the first identifier will be used and + * the import will be a default import of the form `import MyClass from "my-module"`. + * @remarks If `isSideEffects` is `true`, all other options are ignored + * and the import will be a side effects import of the form `import "my-module"`. + * @reference {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#description|MDN} + */ + public requestNewImportDeclaration( + importDeclarationMeta: ImportDeclarationMeta, + isDefault: boolean = false, + isSideEffects: boolean = false + ): void { + const transformer: ts.TransformerFactory = ( + context: ts.TransformationContext + ) => { + return (file) => { + let newStatements = [...file.statements]; + let importDeclarationUpdated = false; + + const identifiers = Array.isArray(importDeclarationMeta.identifiers) + ? importDeclarationMeta.identifiers + : [importDeclarationMeta.identifiers]; + // loop over the statements to find and update the necessary import declaration + for (let i = 0; i < newStatements.length; i++) { + const statement = newStatements[i]; + if ( + ts.isImportDeclaration(statement) && + this.getModuleSpecifierName(statement.moduleSpecifier) === + importDeclarationMeta.moduleName + ) { + // if there are new identifiers to add to an existing import declaration, update it + const namedBindings = statement.importClause?.namedBindings; + if ( + namedBindings && + ts.isNamedImports(namedBindings) && + identifiers.length > 0 + ) { + importDeclarationUpdated = true; + const updatedImportSpecifiers: ts.ImportSpecifier[] = [ + ...namedBindings.elements, + ...identifiers.map(this.createImportSpecifierWithOptionalAlias), + ]; + const updatedNamedImports: ts.NamedImports = + context.factory.updateNamedImports( + namedBindings, + updatedImportSpecifiers + ); + const updatedImportClause: ts.ImportClause = + context.factory.updateImportClause( + statement.importClause, + false, + statement.importClause.name, + updatedNamedImports + ); + + newStatements[i] = context.factory.updateImportDeclaration( + statement, + statement.modifiers, + updatedImportClause, + statement.moduleSpecifier, + statement.attributes + ); + } + // exit the loop after modifying the existing import declaration + break; + } + } + + // if no import declaration was updated and there are identifiers to add, + // create a new import declaration with the identifiers + if ( + !importDeclarationUpdated && + identifiers.length > 0 && + identifiers.length > 0 + ) { + const newImportDeclaration = this.createImportDeclaration( + importDeclarationMeta, + isDefault, + isSideEffects + ); + newStatements = [ + ...file.statements.filter(ts.isImportDeclaration), + newImportDeclaration, + ...file.statements.filter((s) => !ts.isImportDeclaration(s)), + ]; + } + + return ts.factory.updateSourceFile(file, newStatements); + }; + }; + + const id = Array.isArray(importDeclarationMeta.identifiers) + ? importDeclarationMeta.identifiers.join(UNDERSCORE_TOKEN) + : importDeclarationMeta.identifiers.name; + this.requestChange( + id, + ChangeType.NewNode, + transformer, + SyntaxKind.ImportDeclaration + ); + } + + /** + * Applies the requested changes to the source file. + * @remarks Does not mutate the original `ts.SourceFile`. Instead, it creates a new one with the changes applied. + */ + public applyChanges(): ts.SourceFile { + let clone = this.sourceFile.getSourceFile(); + for (const [_id, transformer] of this.transformations) { + clone = ts.transform( + clone, + [transformer.transformerFactory], + this.compilerOptions + ).transformed[0]; + + this._flatNodeRelations = this.createNodeRelationsMap(clone); + } + + this.transformations.clear(); + return clone; + } + + /** + * Applies all transformations, parses the AST and returns the resulting source code. + * @remarks This method should be called after all modifications have been made to the AST. + * If a {@link formatter} is present, it will be used to format the source code. + */ + public finalize(): string { + const finalSource = this.applyChanges(); + if (this.formatter) { + return this.formatter.applyFormatting(finalSource); + } + + return this.printer.printFile(finalSource); + } + + /** + * Determines if a given object is an instance of `PropertyAssignment`. + * @param target The object to check. + */ + public isPropertyAssignment(target: object): target is PropertyAssignment { + return ( + target && + 'name' in target && + 'value' in target && + (ts.isExpression(target.value as any) || + ts.isNumericLiteral(target.value as any) || + ts.isStringLiteral(target.value as any)) + ); + } + + /** + * Determines if a given object is an instance of `ts.NodeArray`. + * @param obj The object to check. + */ + public isNodeArray(obj: object): obj is ts.NodeArray { + // #ref typescript/lib/typescript.js/isNodeArray + return 'pos' in obj && 'end' in obj && !('kind' in obj); + } + + /** + * Requests a change to the source file. + * @param type The type of change to request. + * @param transformer The transformer to apply to the source file during finalization. + * @remarks All aggregated changes will be applied during {@link finalize}. + */ + private requestChange( + id: string = '', + type: ChangeType, + transformer: ts.TransformerFactory, + syntaxKind: SyntaxKind, + node?: T | ts.NodeArray + ): void { + id = `${id}${UNDERSCORE_TOKEN}${crypto.randomUUID()}`; + const requestedChange: ChangeRequest = { + id, + type, + transformerFactory: transformer, + syntaxKind, + node, + }; + + this.transformations.set(id, requestedChange); + } + + /** + * Gathers all import declarations and separates them by their unique identifiers. + * Will assign a template identifier for side effects imports. + * @param statements The statements to search for import declarations. + * + * @remarks Distinguishes between the following import types: + * + * `import { X, Y... } from "module"` - a named import with an import clause that has named bindings - `{ X, Y... }` + * + * `import X from "module"` - a default import, it has an import clause without named bindings, it only has a name - `X` + * + * `import "module"` - a side effects import, it has no import clause + * + * It considers only top-level imports as valid, any imports that are not at the top of the file will be ignored. + */ + private gatherImportDeclarations( + statements: ts.Statement[] + ): Map { + const allImportedIdentifiers = new Map(); + let i = 0; + for (const statement of statements) { + if (!ts.isImportDeclaration(statement)) { + // import declarations are at the top of the file, + // so we can safely break when we reach a node that is not an import declaration + break; + } + + if (!statement.importClause) { + // a side effects import declaration + const sideEffectsName = `${SIDE_EFFECTS_IMPORT_TEMPLATE_NAME}_${++i}`; + allImportedIdentifiers.set(sideEffectsName, { + identifiers: { name: sideEffectsName }, + moduleName: this.getModuleSpecifierName(statement.moduleSpecifier), + }); + continue; + } + + const importClause = statement.importClause; + if (!importClause.namedBindings) { + // a default import declaration + allImportedIdentifiers.set(importClause.name.text, { + identifiers: { name: importClause.name.text }, + moduleName: this.getModuleSpecifierName(statement.moduleSpecifier), + }); + continue; + } + + const namedBindings = importClause.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + // a named import declaration with a list of named bindings + for (const element of namedBindings.elements) { + const identifierName = element.propertyName + ? element.propertyName.text + : element.name.text; + const alias = element.propertyName ? element.name.text : undefined; + allImportedIdentifiers.set(identifierName, { + identifiers: { + name: identifierName, + alias, + }, + moduleName: this.getModuleSpecifierName(statement.moduleSpecifier), + }); + } + } + } + + return allImportedIdentifiers; + } + + /** + * Creates an import specifier with an optional alias. + * @param identifier The identifier to import. + */ + private createImportSpecifierWithOptionalAlias( + identifier: Identifier + ): ts.ImportSpecifier { + // the last arg of `createImportSpecifier` is required - this is where the alias goes + // the second arg is optional, this is where the name goes, hence the following + const aliasOrName = identifier.alias || identifier.name; + return ts.factory.createImportSpecifier( + false, // is type only + identifier.alias + ? ts.factory.createIdentifier(identifier.name) + : undefined, + ts.factory.createIdentifier(aliasOrName) + ); + } + + /** + * Get a module specifier's node text representation. + * @param moduleSpecifier the specifier to get the name of. + * @remarks This method is used to get the name of a module specifier in an import declaration. + * It should always be a string literal. + */ + private getModuleSpecifierName(moduleSpecifier: ts.Expression): string { + if (ts.isStringLiteral(moduleSpecifier)) { + return moduleSpecifier.text; + } + + // a module specifier should always be a string literal, so this should never be reached + throw new Error('Invalid module specifier.'); + } + + /** + * Maps a `KeyValuePair` type to a `ts.ObjectLiteralElementLike` type. + * @param kvp The key-value pair to map. + * @param transform Resolves the `ts.Expression` for the the initializer of the `ts.ObjectLiteralElementLike`. + */ + private mapKeyValuePairToObjectLiteral( + kvp: KeyValuePair, + transform: (value: T) => ts.Expression + ): ts.ObjectLiteralElementLike[] { + return Object.entries(kvp).map(([key, value]) => + ts.factory.createPropertyAssignment(key, transform(value)) + ); + } + + /** + * Creates a flat map of nodes with their parent nodes. + * @param rootNode The node to create a map for. + */ + private createNodeRelationsMap(rootNode: ts.Node): Map { + const flatNodeRelations = new Map(); + function visit(node: ts.Node, parent: ts.Node | null) { + if (parent) { + flatNodeRelations.set(node, parent); + } + ts.forEachChild(node, (child) => visit(child, node)); + } + + visit(rootNode, null); + return flatNodeRelations; + } +} diff --git a/spec/unit/typescript-ast-transformer.spec.ts b/spec/unit/TypeScript-AST-Transformer-spec.ts similarity index 99% rename from spec/unit/typescript-ast-transformer.spec.ts rename to spec/unit/TypeScript-AST-Transformer-spec.ts index 400b95568..cfa0ebcfc 100644 --- a/spec/unit/typescript-ast-transformer.spec.ts +++ b/spec/unit/TypeScript-AST-Transformer-spec.ts @@ -80,7 +80,7 @@ describe('TypeScript AST Transformer', () => { const targetChild = astTransformer .findVariableDeclaration('myVar', 'string') ?.getChildAt(4)!; - const variableDeclaration = astTransformer.findNodeAncenstor( + const variableDeclaration = astTransformer.findNodeAncestor( targetChild, ts.isVariableDeclaration )!; From f7ea2a4d8f57b5f1edb531df76d05c541df754e1 Mon Sep 17 00:00:00 2001 From: jackofdiamond5 Date: Wed, 3 Apr 2024 17:21:34 +0300 Subject: [PATCH 4/5] feat(core): add FormattingService --- packages/core/types/ISourceManager.ts | 8 - .../typescript/TypeScriptFormattingService.ts | 156 ++++++++++++++++++ 2 files changed, 156 insertions(+), 8 deletions(-) delete mode 100644 packages/core/types/ISourceManager.ts create mode 100644 packages/core/typescript/TypeScriptFormattingService.ts diff --git a/packages/core/types/ISourceManager.ts b/packages/core/types/ISourceManager.ts deleted file mode 100644 index 287ce82bc..000000000 --- a/packages/core/types/ISourceManager.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { VFSLanguageService } from "./VFSLanguageService"; -import ts from "typescript"; - -export interface ISourceManager { - getSourceFile(filePath: string, content: string): ts.SourceFile | undefined; - updateEnvironment(filesMap: Map): void; - get languageService(): VFSLanguageService | undefined; -} diff --git a/packages/core/typescript/TypeScriptFormattingService.ts b/packages/core/typescript/TypeScriptFormattingService.ts new file mode 100644 index 000000000..e3ee08629 --- /dev/null +++ b/packages/core/typescript/TypeScriptFormattingService.ts @@ -0,0 +1,156 @@ +import * as ts from 'typescript'; +import { + FormattingService, + FormatSettings, + FS_TOKEN, + IFileSystem, +} from '../types'; +import { App } from '../util'; +import { TypeScriptUtils } from './TypeScriptUtils'; + +export class TypeScriptFormattingService implements FormattingService { + private _languageServiceHost: ts.LanguageServiceHost | undefined; + private _formatSettingsFromConfig: FormatSettings = {}; + private _defaultFormatSettings: FormatSettings = { + indentSize: 4, + tabSize: 4, + newLineCharacter: ts.sys.newLine, + convertTabsToSpaces: true, + indentStyle: ts.IndentStyle.Smart, + insertSpaceAfterCommaDelimiter: true, + insertSpaceAfterSemicolonInForStatements: true, + insertSpaceBeforeAndAfterBinaryOperators: true, + insertSpaceAfterKeywordsInControlFlowStatements: true, + insertSpaceAfterTypeAssertion: true, + singleQuotes: true, + }; + + /** + * Create a new formatting service for the given source file. + * @param path Path to the source file to format. + * @param formatSettings Custom formatting settings to apply. + * @param compilerOptions Compiler options to use when transforming the source file. + */ + constructor( + public path: string, + private readonly formatSettings?: FormatSettings + ) {} + + /** + * Apply formatting to a source file. + */ + public applyFormatting(sourceFile: ts.SourceFile): string { + this.readFormatConfigs(); + + const languageService = ts.createLanguageService( + this.getOrCreateLanguageServiceHost(sourceFile), + ts.createDocumentRegistry() + ); + const changes = languageService.getFormattingEditsForDocument( + sourceFile.fileName, + this.formatOptions + ); + const text = this.applyChanges( + TypeScriptUtils.getSourceText(sourceFile), + changes + ); + + TypeScriptUtils.saveFile(this.path, text); + return text; + } + + /** + * The format options to use when printing the source file. + */ + public get formatOptions(): FormatSettings { + return Object.assign( + {}, + this._defaultFormatSettings, + this._formatSettingsFromConfig, + this.formatSettings + ); + } + + /** + * Creates a language service host for the source file. + * The host is used by TS to access the FS and read the source file. + * In this case we are operating on a single source file so we only need to provide its name and contents. + * @param sourceFile The source file to create the host for. + */ + private getOrCreateLanguageServiceHost( + sourceFile: ts.SourceFile + ): ts.LanguageServiceHost { + if (this._languageServiceHost) return this._languageServiceHost; + + const servicesHost: ts.LanguageServiceHost = { + getCompilationSettings: () => ({}), + getScriptFileNames: () => [sourceFile.fileName], + getScriptVersion: (_fileName) => '0', + getScriptSnapshot: (_fileName) => { + return ts.ScriptSnapshot.fromString( + TypeScriptUtils.getSourceText(sourceFile) + ); + }, + getCurrentDirectory: () => process.cwd(), + getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), + readDirectory: () => [], + readFile: () => undefined, + fileExists: () => true, + }; + + return servicesHost; + } + + /** + * Apply formatting changes (position based) in reverse + * from https://github.com/Microsoft/TypeScript/issues/1651#issuecomment-69877863 + */ + private applyChanges(orig: string, changes: ts.TextChange[]): string { + let result = orig; + for (let i = changes.length - 1; i >= 0; i--) { + const change = changes[i]; + const head = result.slice(0, change.span.start); + const tail = result.slice(change.span.start + change.span.length); + result = head + change.newText + tail; + } + + return result; + } + + /** + * Try and parse formatting from project `.editorconfig` + */ + private readFormatConfigs() { + const fileSystem = App.container.get(FS_TOKEN); + const editorConfigPath = '.editorconfig'; + if (fileSystem.fileExists(editorConfigPath)) { + // very basic parsing support + const text = fileSystem.readFile(editorConfigPath, 'utf-8'); + if (!text) return; + const options = text + .replace(/\s*[#;].*([\r\n])/g, '$1') //remove comments + .replace(/\[(?!\*\]|\*.ts).+\][^\[]+/g, '') // leave [*]/[*.ts] sections + .split(/\r\n|\r|\n/) + .reduce((obj: any, x) => { + if (x.indexOf('=') !== -1) { + const pair = x.split('='); + obj[pair[0].trim()] = pair[1].trim(); + } + return obj; + }, {}); + + this._formatSettingsFromConfig.convertTabsToSpaces = + options['indent_style'] === 'space'; + if (options['indent_size']) { + this._formatSettingsFromConfig.indentSize = + parseInt(options['indent_size'], 10) || + this._formatSettingsFromConfig.indentSize; + } + if (options['quote_type']) { + this._formatSettingsFromConfig.singleQuotes = + options['quote_type'] === 'single'; + } + } + // TODO: consider adding eslint support + } +} From 2b2d4b0336deb4d1a4922b5edb57120a9bec12f2 Mon Sep 17 00:00:00 2001 From: jackofdiamond5 Date: Tue, 7 May 2024 16:09:12 +0300 Subject: [PATCH 5/5] refactor(ts-utils): separate logic for printing source file --- packages/core/typescript/TypeScriptUtils.ts | 49 ++++++++++++++----- .../unit/ts-transform/TypeScriptUtils-spec.ts | 7 ++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/core/typescript/TypeScriptUtils.ts b/packages/core/typescript/TypeScriptUtils.ts index ef5366083..6188357c6 100644 --- a/packages/core/typescript/TypeScriptUtils.ts +++ b/packages/core/typescript/TypeScriptUtils.ts @@ -104,16 +104,37 @@ export class TypeScriptUtils { * Returns an source file, adds new line placeholders as the TS parser won't add `SyntaxKind.NewLineTrivia` to the AST. * @param filePath Path of file to read */ - public static getFileSource(filePath: string): ts.SourceFile { + public static getFileSource(filePath: string, useJSX: boolean = false): ts.SourceFile { const fileSystem = App.container.get(FS_TOKEN); let targetFile = fileSystem.readFile(filePath); targetFile = targetFile.replace(/(\r?\n)(\r?\n)/g, `$1${this.newLinePlaceHolder}$2`); - const targetSource = this.createSourceFile(filePath, targetFile, ts.ScriptTarget.Latest, true); + const targetSource = this.createSourceFile(filePath, targetFile, ts.ScriptTarget.Latest, true, useJSX); return targetSource; } - public static createSourceFile(filePath: string, text: string, scriptTarget: ts.ScriptTarget, setParentNodes?: boolean): ts.SourceFile { - return ts.createSourceFile(filePath, text, scriptTarget, setParentNodes); + /** + * Retrieves the source text, removes new line placeholders + * @param source The source AST to print + */ + public static getSourceText(source: ts.SourceFile): string { + const printer = this.createPrinter(); + let text = printer.printFile(source); + text = text.replace( + new RegExp(`(\r?\n)\\s*?${Util.escapeRegExp(this.newLinePlaceHolder)}(\r?\n)`, "g"), + `$1$2` + ); + + return text; + } + + public static createSourceFile(filePath: string, text: string, + scriptTarget: ts.ScriptTarget, setParentNodes?: boolean, useJSX: boolean = false): ts.SourceFile { + return ts.createSourceFile( + filePath, + text, + scriptTarget, + setParentNodes, + useJSX ? ts.ScriptKind.JS : ts.ScriptKind.TS); } public static createPrinter(): ts.Printer { @@ -124,20 +145,22 @@ export class TypeScriptUtils { } /** - * Prints source, removes new line placeholders and saves the output in a target file + * Saves the source to a target file * @param filePath File path * @param source Source AST to print */ - public static saveFile(filePath: string, source: ts.SourceFile) { + public static saveFile(filePath: string, sourceFile: ts.SourceFile); + public static saveFile(filePath: string, content: string); + public static saveFile(filePath: string, sourceOrContent: ts.SourceFile | string) { const fileSystem = App.container.get(FS_TOKEN); // https://github.com/Microsoft/TypeScript/issues/10786#issuecomment-288987738 - const printer: ts.Printer = this.createPrinter(); - let text = printer.printFile(source); - text = text.replace( - new RegExp(`(\r?\n)\\s*?${Util.escapeRegExp(this.newLinePlaceHolder)}(\r?\n)`, "g"), - `$1$2` - ); - fileSystem.writeFile(filePath, text); + if (typeof sourceOrContent === "object" && ts.isSourceFile(sourceOrContent)) { + const text = this.getSourceText(sourceOrContent); + fileSystem.writeFile(filePath, text); + return; + } + + fileSystem.writeFile(filePath, sourceOrContent); } //#endregion Utility functions diff --git a/spec/unit/ts-transform/TypeScriptUtils-spec.ts b/spec/unit/ts-transform/TypeScriptUtils-spec.ts index 9ca7bc45f..c9bde64bb 100644 --- a/spec/unit/ts-transform/TypeScriptUtils-spec.ts +++ b/spec/unit/ts-transform/TypeScriptUtils-spec.ts @@ -34,7 +34,7 @@ describe("Unit - TypeScriptUtils", () => { expect(TypeScriptUtils.createSourceFile).toHaveBeenCalledWith( `test/file${key}.ts`, expectedText.join(newLines[key]), - ts.ScriptTarget.Latest, true + ts.ScriptTarget.Latest, true, false ); } @@ -56,13 +56,12 @@ describe("Unit - TypeScriptUtils", () => { spyOn(fs, "writeFileSync"); const printerSpy = spyOn(TypeScriptUtils, "createPrinter"); - // tslint:disable-next-line:forin for (const key in newLines) { const printer = jasmine.createSpyObj("", { printFile: sourceText.join(newLines[key]) }); - const source: ts.SourceFile = {} as any; + const source = ts.createSourceFile("", "", ts.ScriptTarget.Latest, true); printerSpy.and.returnValue(printer); - const result = TypeScriptUtils.saveFile(`test/file${key}.ts`, source); + const _result = TypeScriptUtils.saveFile(`test/file${key}.ts`, source); expect(printer.printFile).toHaveBeenCalledWith(source); expect(fs.writeFileSync).toHaveBeenCalledWith(`test/file${key}.ts`, expectedText.join(newLines[key])); }