From cc1b06e9142f89afaaf32f7588611b9f90c3de16 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 30 Oct 2017 11:03:36 -0400 Subject: [PATCH] refactor(@ngtools/webpack): redesign entry module replacer --- .../webpack/src/angular_compiler_plugin.ts | 39 ++- .../entry_module_replacer.spec.ts | 290 ++++++++++++++++ .../src/transformers/entry_module_replacer.ts | 320 ++++++++++++++++++ .../webpack/src/transformers/index.ts | 1 + .../webpack/src/transformers/utilities.ts | 116 +++++++ 5 files changed, 759 insertions(+), 7 deletions(-) create mode 100644 packages/@ngtools/webpack/src/transformers/entry_module_replacer.spec.ts create mode 100644 packages/@ngtools/webpack/src/transformers/entry_module_replacer.ts create mode 100644 packages/@ngtools/webpack/src/transformers/utilities.ts diff --git a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts index 78fde902c826..2c10c0981c6d 100644 --- a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts @@ -18,7 +18,7 @@ import { } from './virtual_file_system_decorator'; import { resolveEntryModuleFromMain } from './entry_resolver'; import { - replaceBootstrap, + createEntryModuleReplacer, exportNgFactory, exportLazyModuleMap, registerLocaleData, @@ -342,7 +342,7 @@ export class AngularCompilerPlugin implements Tapable { }) .then(() => { // If there's still no entryModule try to resolve from mainPath. - if (!this._entryModule && this._mainPath) { + if (!this._ngCompilerSupportsNewApi && !this._entryModule && this._mainPath) { time('AngularCompilerPlugin._make.resolveEntryModuleFromMain'); this._entryModule = resolveEntryModuleFromMain( this._mainPath, this._compilerHost, this._getTsProgram()); @@ -638,6 +638,36 @@ export class AngularCompilerPlugin implements Tapable { const getEntryModule = () => this.entryModule; const getLazyRoutes = () => this._lazyRoutes; + if (this._platform === PLATFORM.Browser && !this._JitMode) { + const entryModuleReplacer = createEntryModuleReplacer( + () => this._getTsProgram().getTypeChecker(), + { + resolveModule: (moduleName, containingFile) => { + const resolved = ts.resolveModuleName( + moduleName, + containingFile, + this._compilerOptions, + this._compilerHost, + ); + if (!resolved || !resolved.resolvedModule) { + return undefined; + } + if (resolved.resolvedModule.extension !== ts.Extension.Ts) { + return undefined; + } + const resolvedFileName = resolved.resolvedModule.resolvedFileName; + return resolvedFileName.replace(/\.ts$/, ''); + }, + onEntryFound: (entryPath, entryClass) => { + if (!this._entryModule) { + this._entryModule = `${entryPath}#${entryClass}`; + } + }, + }, + ); + this._transformers.push(entryModuleReplacer); + } + if (this._JitMode) { // Replace resources in JIT. this._transformers.push(replaceResources(isAppPath)); @@ -651,11 +681,6 @@ export class AngularCompilerPlugin implements Tapable { this._transformers.push(registerLocaleData(isAppPath, getEntryModule, this._compilerOptions.i18nInLocale)); } - - if (!this._JitMode) { - // Replace bootstrap in browser AOT. - this._transformers.push(replaceBootstrap(isAppPath, getEntryModule)); - } } else if (this._platform === PLATFORM.Server) { this._transformers.push(exportLazyModuleMap(isMainPath, getLazyRoutes)); if (!this._JitMode) { diff --git a/packages/@ngtools/webpack/src/transformers/entry_module_replacer.spec.ts b/packages/@ngtools/webpack/src/transformers/entry_module_replacer.spec.ts new file mode 100644 index 000000000000..de35b0781d0c --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/entry_module_replacer.spec.ts @@ -0,0 +1,290 @@ +import { oneLine } from 'common-tags'; +import * as ts from 'typescript'; +import { createEntryModuleReplacer, EntryModuleReplacerOptions } from './entry_module_replacer'; + +type TransformerFactoryCreator = + (typeChecker: () => ts.TypeChecker, options?: TOptions) => ts.TransformerFactory; + +function transform( + content: string, + creator?: TransformerFactoryCreator, + options?: TOptions, + module?: ts.ModuleKind, +): string | undefined { + let result: string | undefined; + const source = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest); + const compilerOptions: ts.CompilerOptions = { + isolatedModules: true, + noLib: true, + noResolve: true, + target: ts.ScriptTarget.Latest, + importHelpers: true, + module, + }; + const compilerHost: ts.CompilerHost = { + getSourceFile: (fileName) => { + if (fileName === source.fileName) { + return source; + } + throw new Error(); + }, + getDefaultLibFileName: () => 'lib.d.ts', + writeFile: (fileName, data) => { + if (fileName === 'temp.js') { + result = data; + } + }, + getCurrentDirectory: () => '', + getDirectories: () => [], + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => false, + getNewLine: () => '\n', + fileExists: (fileName) => fileName === source.fileName, + readFile: (_fileName) => '', + }; + + const program = ts.createProgram([source.fileName], compilerOptions, compilerHost); + + const factory = creator ? creator(program.getTypeChecker, options) : undefined; + const transformers = factory ? { before: [factory] } : undefined; + program.emit(undefined, undefined, undefined, undefined, transformers); + + return result; +} + +function expectTransformation( + input: string, + output: string, + options?: EntryModuleReplacerOptions, + module?: ts.ModuleKind, +): void { + const inputResult = transform(input, createEntryModuleReplacer, options, module); + + expect(oneLine`${inputResult}`).toEqual(oneLine`${output}`); +} + +describe('entry module replacer', () => { + + it('replaces bootstrap call from default main.ts', () => { + const input = ` + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + + if (environment.production) { + enableProdMode(); + } + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { enableProdMode } from '@angular/core'; + + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + + if (environment.production) { + enableProdMode(); + } + + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('replaces bootstrap call directly from platform call', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { AppModule } from './app/app.module'; + + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('only removes unused specifiers from relevant imports', () => { + const input = ` + import { platformBrowserDynamic, VERSION } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + console.log(VERSION); + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { VERSION } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + console.log(VERSION); + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('keeps used relevant imports', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + const test = platformBrowserDynamic(); + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + const test = platformBrowserDynamic(); + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('replaces bootstrap call with CommonJS output', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + /* tslint:disable:max-line-length */ + const output = ` + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + const platformBrowser_1 = require("@angular/platform-browser"); + const AppModuleNgFactory_1 = require("./app/app.module.ngfactory"); + const app_module_1 = require("./app/app.module"); + platformBrowser_1.platformBrowser().bootstrapModuleFactory(AppModuleNgFactory_1.AppModuleNgFactory); + `; + /* tslint:enable:max-line-length */ + + expectTransformation(input, output, undefined, ts.ModuleKind.CommonJS); + }); + + it('replaces bootstrap call with aliased imports (platform only)', () => { + const input = ` + import { platformBrowserDynamic as pbd } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + pbd().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { AppModule } from './app/app.module'; + + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('replaces bootstrap call with namespace imports', () => { + const input = ` + import * as PBD from '@angular/platform-browser-dynamic'; + import * as AM from './app/app.module'; + + PBD.platformBrowserDynamic().bootstrapModule(AM.AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import * as AM from './app/app.module'; + + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + + it('replaces a path mapped `App` module', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from '@app/app.module'; + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { AppModule } from '@app/app.module'; + + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + const options: EntryModuleReplacerOptions = { + resolveModule: (moduleText: string, containingFile: string) => { + expect(moduleText).toBe('@app/app.module'); + expect(containingFile).toBe('temp.ts'); + + return './app/app.module'; + } + }; + + expectTransformation(input, output, options); + }); + + it('replaces bootstrap inside anonymous function', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + const bootstrap = () => { + platformBrowserDynamic().bootstrapModule(AppModule); + }; + + bootstrap(); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { AppModule } from './app/app.module'; + + const bootstrap = () => { + platformBrowser_1().bootstrapModuleFactory(AppModuleNgFactory_1); + }; + + bootstrap(); + `; + + expectTransformation(input, output); + }); + + xit('replaces bootstrap call from platform variable', () => { + const input = ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + const platform = platformBrowserDynamic(); + platform.bootstrapModule(AppModule); + `; + const output = ` + import { platformBrowser as platformBrowser_1 } from "@angular/platform-browser"; + import { AppModuleNgFactory as AppModuleNgFactory_1 } from "./app/app.module.ngfactory"; + import { AppModule } from './app/app.module'; + + const platform = platformBrowser_1(); + platform.bootstrapModuleFactory(AppModuleNgFactory_1); + `; + + expectTransformation(input, output); + }); + +}); diff --git a/packages/@ngtools/webpack/src/transformers/entry_module_replacer.ts b/packages/@ngtools/webpack/src/transformers/entry_module_replacer.ts new file mode 100644 index 000000000000..fc723d40b72c --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/entry_module_replacer.ts @@ -0,0 +1,320 @@ +import * as ts from 'typescript'; +import { + cleanupImport, + createNamespaceImport, + createSingleImport, + fixupNodeSymbol, + getDeclarations, +} from './utilities'; + +export type ModuleResolver = + (moduleText: string, containingFile: string) => string | undefined; + +export class EntryModuleReplacerOptions { + factoryClassSuffix? = 'NgFactory'; + factoryPathSuffix? = '.ngfactory'; + resolveModule?: ModuleResolver = moduleText => moduleText; + onEntryFound?: (entryPath: string, entryClass: string, containingFile: string) => void; +} + +interface ImportDescription { + readonly name: string; + readonly module: string; + readonly node: ts.ImportSpecifier | ts.NamespaceImport; + readonly isNamespace?: boolean; +} + +interface ImportInfo { + moduleText: string; + modulePath: string; + name: string; +} + +export function createEntryModuleReplacer( + getTypeChecker: () => ts.TypeChecker, + options?: EntryModuleReplacerOptions +): ts.TransformerFactory { + options = { ...(new EntryModuleReplacerOptions()), ...options }; + + let typeChecker: ts.TypeChecker | undefined; + let moduleKind: ts.ModuleKind; + let currentSourceFileName: string | null; + const relevantImports = new Map(); + const newImports = new Set(); + let removableImports: Set | null; + + function notifyEntryFound(entryPath: string, entryClass: string) { + if (options.onEntryFound) { + options.onEntryFound(entryPath, entryClass, currentSourceFileName); + } + } + + function getRelevantImports(identifier: ts.Identifier): Array { + return getDeclarations(identifier, typeChecker) + .filter(dec => relevantImports.has(dec)) + .map(dec => relevantImports.get(dec)); + } + + function getImportInfo(expression: ts.Expression): ImportInfo | undefined { + let name: string; + let importDeclarations: Array; + + if (ts.isIdentifier(expression)) { + // direct import + name = expression.text; + importDeclarations = getDeclarations(expression, typeChecker) + .filter(ts.isImportSpecifier) + .map(specifier => specifier.parent.parent.parent); + } else if (ts.isPropertyAccessExpression(expression) + && ts.isIdentifier(expression.expression)) { + // namespace import + name = expression.name.text; + importDeclarations = getDeclarations(expression.expression, typeChecker) + .filter(ts.isNamespaceImport) + .map(ni => ni.parent.parent); + } else { + return undefined; + } + + const importPaths = importDeclarations + .map(dec => dec.moduleSpecifier) + .filter(ts.isStringLiteral) + .map(literal => literal.text); + + if (importPaths.length === 0) { + return undefined; + } + + const moduleText = importPaths[0]; + const modulePath = options.resolveModule(moduleText, currentSourceFileName); + + return { moduleText, modulePath, name }; + } + + function createTargetExpression(module: string, name: string): ts.Expression { + // create an additional import; added to source file after full visit + const uniqueName = ts.createUniqueName(name); + if (moduleKind === ts.ModuleKind.CommonJS) { + newImports.add(createNamespaceImport(module, uniqueName)); + return ts.createPropertyAccess(uniqueName, name); + } else { + newImports.add(createSingleImport(module, name, uniqueName)); + return uniqueName; + } + } + + function findReplacement(description: ImportDescription) { + if (description.module === '@angular/platform-browser-dynamic') { + return { + module: '@angular/platform-browser', + platform: 'platformBrowser', + action: 'bootstrapModuleFactory' + }; + } + + return undefined; + } + + function visitCallTarget(node: ts.Expression): ts.VisitResult { + if (!ts.isPropertyAccessExpression(node)) { + return node; + } + + const propertySource = node.expression; + if (ts.isCallExpression(propertySource) + && propertySource.arguments.length === 0) { + // potential platform factory call + + let identifier: ts.Identifier; + if (ts.isIdentifier(propertySource.expression)) { + // potential named import call + identifier = propertySource.expression; + } else if (ts.isPropertyAccessExpression(propertySource.expression) + && ts.isIdentifier(propertySource.expression.expression)) { + // potential namespace import call + identifier = propertySource.expression.expression; + } + + if (!identifier) { + // unsupported or not relevant + return node; + } + + const relevant = getRelevantImports(identifier); + + if (relevant.length > 0) { + // platform factory call + const replacement = findReplacement(relevant[0]); + if (!replacement) { + // no replacement found + return node; + } + + return ts.updatePropertyAccess( + node, + ts.updateCall( + propertySource, + createTargetExpression(replacement.module, replacement.platform), + undefined, + [] + ), + ts.createIdentifier(replacement.action) + ); + } + } + + return node; + } + + function isPotentialBootstrapCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + // bootstrap calls have 1-2 arguments + if (node.arguments.length === 0 || node.arguments.length > 2) { + return false; + } + + const arg0 = node.arguments[0]; + + // support identifiers as first argument + if (arg0.kind === ts.SyntaxKind.Identifier) { + return true; + } + + // support potential namespace property access + if (ts.isPropertyAccessExpression(arg0) + && arg0.expression.kind === ts.SyntaxKind.Identifier) { + return true; + } + + return false; + } + + const transformerFactory = (context: ts.TransformationContext) => { + typeChecker = getTypeChecker(); + if (!typeChecker) { + throw new Error('TypeScript type checker is required.'); + } + moduleKind = context.getCompilerOptions().module; + + function visit(node: ts.Node): ts.VisitResult { + if (ts.isImportDeclaration(node)) { + return node; + } + + if (ts.isIdentifier(node) && removableImports.size > 0) { + // record used imports + getDeclarations(node, typeChecker) + .forEach(dec => removableImports.delete(dec)); + } + + if (isPotentialBootstrapCall(node)) { + const updatedEntryTarget = ts.visitNode(node.expression, visitCallTarget); + if (updatedEntryTarget !== node.expression) { + // presence of first argument has already been checked + const classImportInfo = getImportInfo(node.arguments[0]); + + if (classImportInfo) { + notifyEntryFound(classImportInfo.modulePath, classImportInfo.name); + // call target expression was updated so update the call itself + // swap in the factory argument and add import as well + const factoryClassName = classImportInfo.name + options.factoryClassSuffix; + const factoryPath = classImportInfo.modulePath + options.factoryPathSuffix; + const argExpression = createTargetExpression(factoryPath, factoryClassName); + + node = ts.updateCall( + node, + updatedEntryTarget, + undefined, + [ argExpression, ...node.arguments.slice(1) ] + ); + } + } + } + + return ts.visitEachChild(node, visit, context); + } + + function findEntryImports(node: ts.Node): void { + if (!ts.isImportDeclaration(node)) { + return; + } + + if (!node.moduleSpecifier + || !ts.isStringLiteral(node.moduleSpecifier) + || !node.importClause + || !node.importClause.namedBindings) { + return; + } + + const module = node.moduleSpecifier.text; + if (module === '@angular/platform-browser-dynamic') { + if (ts.isNamespaceImport(node.importClause.namedBindings)) { + relevantImports.set(node.importClause.namedBindings, { + node: node.importClause.namedBindings, + name: node.importClause.namedBindings.name.text, + isNamespace: true, + module + }); + } else { + node.importClause.namedBindings.elements + .map(el => ({ + node: el, + name: (el.propertyName || el.name).text, + module + })) + .filter(desc => desc.name === 'platformBrowserDynamic') + .forEach(desc => relevantImports.set(desc.node, desc)); + } + } + + } + + return (file: ts.SourceFile) => { + currentSourceFileName = file.fileName; + + // try to find any entry related imports + ts.forEachChild(file, findEntryImports); + if (relevantImports.size === 0) { + // no changes needed + return file; + } + + removableImports = new Set(relevantImports.keys()); + + let result = ts.visitEachChild(file, visit, context); + + if (newImports.size > 0 || removableImports.size > 0) { + const importVisitor = (node: ts.Node) => { + if (!ts.isImportDeclaration(node)) { + return node; + } + return cleanupImport(node, dec => removableImports.has(dec)); + }; + const statements = ts.visitNodes(result.statements, importVisitor); + const insertionPoint = statements + .findIndex(s => !(ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression))); + + statements.splice(insertionPoint, 0, ...newImports); + + result = ts.updateSourceFileNode( + result, + statements + ); + + newImports.clear(); + } + + // cleanup + relevantImports.clear(); + removableImports = null; + currentSourceFileName = null; + + return fixupNodeSymbol(result); + }; + }; + + return transformerFactory; +} diff --git a/packages/@ngtools/webpack/src/transformers/index.ts b/packages/@ngtools/webpack/src/transformers/index.ts index 029a645c539b..c7c4f80d7def 100644 --- a/packages/@ngtools/webpack/src/transformers/index.ts +++ b/packages/@ngtools/webpack/src/transformers/index.ts @@ -8,3 +8,4 @@ export * from './export_ngfactory'; export * from './export_lazy_module_map'; export * from './register_locale_data'; export * from './replace_resources'; +export * from './entry_module_replacer'; diff --git a/packages/@ngtools/webpack/src/transformers/utilities.ts b/packages/@ngtools/webpack/src/transformers/utilities.ts new file mode 100644 index 000000000000..326aec28224a --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/utilities.ts @@ -0,0 +1,116 @@ +import * as ts from 'typescript'; + +export function createSingleImport( + module: string, + name: string, + alias?: string | ts.Identifier +): ts.ImportDeclaration { + let nameIdentifier; + let propertyNameIdentifier; + if (alias) { + nameIdentifier = typeof alias === 'string' ? ts.createIdentifier(alias) : alias; + propertyNameIdentifier = ts.createIdentifier(name); + } else { + nameIdentifier = ts.createIdentifier(name); + propertyNameIdentifier = undefined; + } + return ts.createImportDeclaration( + undefined, + undefined, + ts.createImportClause( + undefined, + ts.createNamedImports( + [ + ts.createImportSpecifier( + propertyNameIdentifier, + nameIdentifier + ) + ] + ) + ), + ts.createLiteral(module), + ); +} + +export function createNamespaceImport( + module: string, + name: string | ts.Identifier, +): ts.ImportDeclaration { + const nameIdentifier = typeof name === 'string' ? ts.createIdentifier(name) : name; + return ts.createImportDeclaration( + undefined, + undefined, + ts.createImportClause( + undefined, + ts.createNamespaceImport(nameIdentifier) + ), + ts.createLiteral(module), + ); +} + +export function getDeclarations( + identifier: ts.Identifier, + typeChecker: ts.TypeChecker, +): Array { + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (!symbol || !symbol.declarations) { + return []; + } + + return symbol.declarations; +} + +export function cleanupImport( + node: ts.ImportDeclaration, + canRemove: (declaration: ts.Declaration) => boolean, +): ts.VisitResult { + if (!node.importClause) { + return node; + } + + const importClause = ts.visitNode(node.importClause, (node: ts.ImportClause) => { + if (node.name || !node.namedBindings) { + return canRemove(node) ? undefined : node; + } + + if (ts.isNamespaceImport(node.namedBindings)) { + return canRemove(node.namedBindings) ? undefined : node; + } + + const elements = ts.visitNodes( + node.namedBindings.elements, + (element: ts.ImportSpecifier) => canRemove(element) ? undefined : element, + ); + if (elements && elements.length > 0) { + return ts.updateImportClause( + node, + node.name, + ts.updateNamedImports( + node.namedBindings, + elements + ) + ); + } + return undefined; + }); + + if (importClause) { + return ts.updateImportDeclaration( + node, + node.decorators, + node.modifiers, + importClause, + node.moduleSpecifier + ); + } + + return undefined; +} + +// Workaround TS bug in TS < 2.5 +export function fixupNodeSymbol(node: T): T { + // tslint:disable-next-line:no-any - 'symbol' is internal + (node as any).symbol = (node as any).symbol || (ts.getParseTreeNode(node) as any).symbol; + + return node; +}