diff --git a/packages/compiler-cli/src/ngtsc/cycles/BUILD.bazel b/packages/compiler-cli/src/ngtsc/cycles/BUILD.bazel new file mode 100644 index 0000000000..990d00b790 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/BUILD.bazel @@ -0,0 +1,16 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "cycles", + srcs = glob([ + "index.ts", + "src/**/*.ts", + ]), + module_name = "@angular/compiler-cli/src/ngtsc/cycles", + deps = [ + "//packages/compiler-cli/src/ngtsc/imports", + "@ngdeps//typescript", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/cycles/index.ts b/packages/compiler-cli/src/ngtsc/cycles/index.ts new file mode 100644 index 0000000000..273598bee5 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export {CycleAnalyzer} from './src/analyzer'; +export {ImportGraph} from './src/imports'; diff --git a/packages/compiler-cli/src/ngtsc/cycles/src/analyzer.ts b/packages/compiler-cli/src/ngtsc/cycles/src/analyzer.ts new file mode 100644 index 0000000000..5d87b47c08 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/src/analyzer.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {ImportGraph} from './imports'; + +/** + * Analyzes a `ts.Program` for cycles. + */ +export class CycleAnalyzer { + constructor(private importGraph: ImportGraph) {} + + /** + * Check whether adding an import from `from` to `to` would create a cycle in the `ts.Program`. + */ + wouldCreateCycle(from: ts.SourceFile, to: ts.SourceFile): boolean { + // Import of 'from' -> 'to' is illegal if an edge 'to' -> 'from' already exists. + return this.importGraph.transitiveImportsOf(to).has(from); + } + + /** + * Record a synthetic import from `from` to `to`. + * + * This is an import that doesn't exist in the `ts.Program` but will be considered as part of the + * import graph for cycle creation. + */ + recordSyntheticImport(from: ts.SourceFile, to: ts.SourceFile): void { + this.importGraph.addSyntheticImport(from, to); + } +} diff --git a/packages/compiler-cli/src/ngtsc/cycles/src/imports.ts b/packages/compiler-cli/src/ngtsc/cycles/src/imports.ts new file mode 100644 index 0000000000..053c1dd277 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/src/imports.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {ModuleResolver} from '../../imports'; + +/** + * A cached graph of imports in the `ts.Program`. + * + * The `ImportGraph` keeps track of dependencies (imports) of individual `ts.SourceFile`s. Only + * dependencies within the same program are tracked; imports into packages on NPM are not. + */ +export class ImportGraph { + private map = new Map>(); + + constructor(private resolver: ModuleResolver) {} + + /** + * List the direct (not transitive) imports of a given `ts.SourceFile`. + * + * This operation is cached. + */ + importsOf(sf: ts.SourceFile): Set { + if (!this.map.has(sf)) { + this.map.set(sf, this.scanImports(sf)); + } + return this.map.get(sf) !; + } + + /** + * Lists the transitive imports of a given `ts.SourceFile`. + */ + transitiveImportsOf(sf: ts.SourceFile): Set { + const imports = new Set(); + this.transitiveImportsOfHelper(sf, imports); + return imports; + } + + private transitiveImportsOfHelper(sf: ts.SourceFile, results: Set): void { + if (results.has(sf)) { + return; + } + results.add(sf); + this.importsOf(sf).forEach(imported => { this.transitiveImportsOfHelper(imported, results); }); + } + + /** + * Add a record of an import from `sf` to `imported`, that's not present in the original + * `ts.Program` but will be remembered by the `ImportGraph`. + */ + addSyntheticImport(sf: ts.SourceFile, imported: ts.SourceFile): void { + if (isLocalFile(imported)) { + this.importsOf(sf).add(imported); + } + } + + private scanImports(sf: ts.SourceFile): Set { + const imports = new Set(); + // Look through the source file for import statements. + sf.statements.forEach(stmt => { + if ((ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) && + stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier)) { + // Resolve the module to a file, and check whether that file is in the ts.Program. + const moduleName = stmt.moduleSpecifier.text; + const moduleFile = this.resolver.resolveModuleName(moduleName, sf); + if (moduleFile !== null && isLocalFile(moduleFile)) { + // Record this local import. + imports.add(moduleFile); + } + } + }); + return imports; + } +} + +function isLocalFile(sf: ts.SourceFile): boolean { + return !sf.fileName.endsWith('.d.ts'); +} diff --git a/packages/compiler-cli/src/ngtsc/cycles/test/BUILD.bazel b/packages/compiler-cli/src/ngtsc/cycles/test/BUILD.bazel new file mode 100644 index 0000000000..0ed3c04e0d --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/test/BUILD.bazel @@ -0,0 +1,27 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob([ + "**/*.ts", + ]), + deps = [ + "//packages:types", + "//packages/compiler-cli/src/ngtsc/cycles", + "//packages/compiler-cli/src/ngtsc/imports", + "//packages/compiler-cli/src/ngtsc/testing", + "@ngdeps//typescript", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/cycles/test/analyzer_spec.ts b/packages/compiler-cli/src/ngtsc/cycles/test/analyzer_spec.ts new file mode 100644 index 0000000000..310deaf23f --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/test/analyzer_spec.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {ModuleResolver} from '../../imports'; +import {CycleAnalyzer} from '../src/analyzer'; +import {ImportGraph} from '../src/imports'; + +import {makeProgramFromGraph} from './util'; + +describe('cycle analyzer', () => { + it('should not detect a cycle when there isn\'t one', () => { + const {program, analyzer} = makeAnalyzer('a:b,c;b;c'); + const b = program.getSourceFile('b.ts') !; + const c = program.getSourceFile('c.ts') !; + expect(analyzer.wouldCreateCycle(b, c)).toBe(false); + expect(analyzer.wouldCreateCycle(c, b)).toBe(false); + }); + + it('should detect a simple cycle between two files', () => { + const {program, analyzer} = makeAnalyzer('a:b;b'); + const a = program.getSourceFile('a.ts') !; + const b = program.getSourceFile('b.ts') !; + expect(analyzer.wouldCreateCycle(a, b)).toBe(false); + expect(analyzer.wouldCreateCycle(b, a)).toBe(true); + }); + + it('should detect a cycle with a re-export in the chain', () => { + const {program, analyzer} = makeAnalyzer('a:*b;b:c;c'); + const a = program.getSourceFile('a.ts') !; + const c = program.getSourceFile('c.ts') !; + expect(analyzer.wouldCreateCycle(a, c)).toBe(false); + expect(analyzer.wouldCreateCycle(c, a)).toBe(true); + }); + + it('should detect a cycle in a more complex program', () => { + const {program, analyzer} = makeAnalyzer('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f:c;g;h:g'); + const b = program.getSourceFile('b.ts') !; + const g = program.getSourceFile('g.ts') !; + expect(analyzer.wouldCreateCycle(b, g)).toBe(false); + expect(analyzer.wouldCreateCycle(g, b)).toBe(true); + }); + + it('should detect a cycle caused by a synthetic edge', () => { + const {program, analyzer} = makeAnalyzer('a:b,c;b;c'); + const b = program.getSourceFile('b.ts') !; + const c = program.getSourceFile('c.ts') !; + expect(analyzer.wouldCreateCycle(b, c)).toBe(false); + analyzer.recordSyntheticImport(c, b); + expect(analyzer.wouldCreateCycle(b, c)).toBe(true); + }); +}); + +function makeAnalyzer(graph: string): {program: ts.Program, analyzer: CycleAnalyzer} { + const {program, options, host} = makeProgramFromGraph(graph); + return { + program, + analyzer: new CycleAnalyzer(new ImportGraph(new ModuleResolver(program, options, host))), + }; +} diff --git a/packages/compiler-cli/src/ngtsc/cycles/test/imports_spec.ts b/packages/compiler-cli/src/ngtsc/cycles/test/imports_spec.ts new file mode 100644 index 0000000000..4dba9dd800 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/test/imports_spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {ModuleResolver} from '../../imports'; +import {ImportGraph} from '../src/imports'; + +import {makeProgramFromGraph} from './util'; + +describe('import graph', () => { + it('should record imports of a simple program', () => { + const {program, graph} = makeImportGraph('a:b;b:c;c'); + const a = program.getSourceFile('a.ts') !; + const b = program.getSourceFile('b.ts') !; + const c = program.getSourceFile('c.ts') !; + expect(importsToString(graph.importsOf(a))).toBe('b'); + expect(importsToString(graph.importsOf(b))).toBe('c'); + }); + + it('should calculate transitive imports of a simple program', () => { + const {program, graph} = makeImportGraph('a:b;b:c;c'); + const a = program.getSourceFile('a.ts') !; + const b = program.getSourceFile('b.ts') !; + const c = program.getSourceFile('c.ts') !; + expect(importsToString(graph.transitiveImportsOf(a))).toBe('a,b,c'); + }); + + it('should calculate transitive imports in a more complex program (with a cycle)', () => { + const {program, graph} = makeImportGraph('a:*b,*c;b:*e,*f;c:*g,*h;e:f;f;g:e;h:g'); + const c = program.getSourceFile('c.ts') !; + expect(importsToString(graph.transitiveImportsOf(c))).toBe('c,e,f,g,h'); + }); + + it('should reflect the addition of a synthetic import', () => { + const {program, graph} = makeImportGraph('a:b,c,d;b;c;d:b'); + const b = program.getSourceFile('b.ts') !; + const c = program.getSourceFile('c.ts') !; + const d = program.getSourceFile('d.ts') !; + expect(importsToString(graph.importsOf(b))).toEqual(''); + expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,d'); + graph.addSyntheticImport(b, c); + expect(importsToString(graph.importsOf(b))).toEqual('c'); + expect(importsToString(graph.transitiveImportsOf(d))).toEqual('b,c,d'); + }); +}); + +function makeImportGraph(graph: string): {program: ts.Program, graph: ImportGraph} { + const {program, options, host} = makeProgramFromGraph(graph); + return { + program, + graph: new ImportGraph(new ModuleResolver(program, options, host)), + }; +} + +function importsToString(imports: Set): string { + return Array.from(imports).map(sf => sf.fileName.substr(1).replace('.ts', '')).sort().join(','); +} diff --git a/packages/compiler-cli/src/ngtsc/cycles/test/util.ts b/packages/compiler-cli/src/ngtsc/cycles/test/util.ts new file mode 100644 index 0000000000..9c0dcc73c4 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/cycles/test/util.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {makeProgram} from '../../testing/in_memory_typescript'; + +/** + * Construct a TS program consisting solely of an import graph, from a string-based representation + * of the graph. + * + * The `graph` string consists of semicolon separated files, where each file is specified + * as a name and (optionally) a list of comma-separated imports or exports. For example: + * + * "a:b,c;b;c" + * + * specifies a program with three files (a.ts, b.ts, c.ts) where a.ts imports from both b.ts and + * c.ts. + * + * A more complicated example has a dependency from b.ts to c.ts: "a:b,c;b:c;c". + * + * A * preceding a file name in the list of imports indicates that the dependency should be an + * "export" and not an "import" dependency. For example: + * + * "a:*b,c;b;c" + * + * represents a program where a.ts exports from b.ts and imports from c.ts. + */ +export function makeProgramFromGraph(graph: string): { + program: ts.Program, + host: ts.CompilerHost, + options: ts.CompilerOptions, +} { + const files = graph.split(';').map(fileSegment => { + const [name, importList] = fileSegment.split(':'); + const contents = (importList ? importList.split(',') : []) + .map(i => { + if (i.startsWith('*')) { + const sym = i.substr(1); + return `export {${sym}} from './${sym}';`; + } else { + return `import {${i}} from './${i}';`; + } + }) + .join('\n') + + `export const ${name} = '${name}';\n`; + return { + name: `${name}.ts`, + contents, + }; + }); + return makeProgram(files); +}