diff --git a/packages/compiler-cli/ngcc/src/main.ts b/packages/compiler-cli/ngcc/src/main.ts index 63c802a247..acee60c7c6 100644 --- a/packages/compiler-cli/ngcc/src/main.ts +++ b/packages/compiler-cli/ngcc/src/main.ts @@ -19,6 +19,7 @@ import {DependencyResolver} from './packages/dependency_resolver'; import {EntryPointFormat, EntryPointJsonProperty, SUPPORTED_FORMAT_PROPERTIES, getEntryPointFormat} from './packages/entry_point'; import {makeEntryPointBundle} from './packages/entry_point_bundle'; import {EntryPointFinder} from './packages/entry_point_finder'; +import {ModuleResolver} from './packages/module_resolver'; import {Transformer} from './packages/transformer'; import {FileWriter} from './writing/file_writer'; import {InPlaceFileWriter} from './writing/in_place_file_writer'; @@ -74,7 +75,8 @@ export function mainNgcc({basePath, targetEntryPointPath, compileAllFormats = true, createNewEntryPointFormats = false, logger = new ConsoleLogger(LogLevel.info)}: NgccOptions): void { const transformer = new Transformer(logger); - const host = new DependencyHost(); + const moduleResolver = new ModuleResolver(); + const host = new DependencyHost(moduleResolver); const resolver = new DependencyResolver(logger, host); const finder = new EntryPointFinder(logger, resolver); const fileWriter = getFileWriter(createNewEntryPointFormats); diff --git a/packages/compiler-cli/ngcc/src/packages/dependency_host.ts b/packages/compiler-cli/ngcc/src/packages/dependency_host.ts index bd959ed568..501d1ccc38 100644 --- a/packages/compiler-cli/ngcc/src/packages/dependency_host.ts +++ b/packages/compiler-cli/ngcc/src/packages/dependency_host.ts @@ -6,16 +6,20 @@ * found in the LICENSE file at https://angular.io/license */ -import * as path from 'canonical-path'; import * as fs from 'fs'; import * as ts from 'typescript'; -import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; + +import {ModuleResolver, ResolvedDeepImport, ResolvedRelativeModule} from './module_resolver'; + + /** * Helper functions for computing dependencies. */ export class DependencyHost { + constructor(private moduleResolver: ModuleResolver) {} /** * Get a list of the resolved paths to all the dependencies of this entry point. * @param from An absolute path to the file whose dependencies we want to get. @@ -24,17 +28,15 @@ export class DependencyHost { * @param missing A set that will have the dependencies that could not be found added to it. * @param deepImports A set that will have the import paths that exist but cannot be mapped to * entry-points, i.e. deep-imports. - * @param internal A set that is used to track internal dependencies to prevent getting stuck in a + * @param alreadySeen A set that is used to track internal dependencies to prevent getting stuck + * in a * circular dependency loop. */ computeDependencies( from: AbsoluteFsPath, dependencies: Set = new Set(), - missing: Set = new Set(), deepImports: Set = new Set(), - internal: Set = new Set()): { - dependencies: Set, - missing: Set, - deepImports: Set - } { + missing: Set = new Set(), deepImports: Set = new Set(), + alreadySeen: Set = new Set()): + {dependencies: Set, missing: Set, deepImports: Set} { const fromContents = fs.readFileSync(from, 'utf8'); if (!this.hasImportOrReexportStatements(fromContents)) { return {dependencies, missing, deepImports}; @@ -49,86 +51,30 @@ export class DependencyHost { // Grab the id of the module that is being imported .map(stmt => stmt.moduleSpecifier.text) // Resolve this module id into an absolute path - .forEach((importPath: PathSegment) => { - if (importPath.startsWith('.')) { - // This is an internal import so follow it - const internalDependency = this.resolveInternal(from, importPath); - // Avoid circular dependencies - if (!internal.has(internalDependency)) { - internal.add(internalDependency); - this.computeDependencies( - internalDependency, dependencies, missing, deepImports, internal); - } - } else { - const resolvedEntryPoint = this.tryResolveEntryPoint(from, importPath); - if (resolvedEntryPoint !== null) { - dependencies.add(resolvedEntryPoint); + .forEach(importPath => { + const resolvedModule = this.moduleResolver.resolveModuleImport(importPath, from); + if (resolvedModule) { + if (resolvedModule instanceof ResolvedRelativeModule) { + const internalDependency = resolvedModule.modulePath; + if (!alreadySeen.has(internalDependency)) { + alreadySeen.add(internalDependency); + this.computeDependencies( + internalDependency, dependencies, missing, deepImports, alreadySeen); + } } else { - // If the import could not be resolved as entry point, it either does not exist - // at all or is a deep import. - const deeplyImportedFile = this.tryResolve(from, importPath); - if (deeplyImportedFile !== null) { - deepImports.add(importPath); + if (resolvedModule instanceof ResolvedDeepImport) { + deepImports.add(resolvedModule.importPath); } else { - missing.add(importPath); + dependencies.add(resolvedModule.entryPointPath); } } + } else { + missing.add(importPath); } }); return {dependencies, missing, deepImports}; } - /** - * Resolve an internal module import. - * @param from the absolute file path from where to start trying to resolve this module - * @param to the module specifier of the internal dependency to resolve - * @returns the resolved path to the import. - */ - resolveInternal(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath { - const fromDirectory = path.dirname(from); - // `fromDirectory` is absolute so we don't need to worry about telling `require.resolve` - // about it by adding it to a `paths` parameter - unlike `tryResolve` below. - return AbsoluteFsPath.from(require.resolve(path.resolve(fromDirectory, to))); - } - - /** - * We don't want to resolve external dependencies directly because if it is a path to a - * sub-entry-point (e.g. @angular/animations/browser rather than @angular/animations) - * then `require.resolve()` may return a path to a UMD bundle, which may actually live - * in the folder containing the sub-entry-point - * (e.g. @angular/animations/bundles/animations-browser.umd.js). - * - * Instead we try to resolve it as a package, which is what we would need anyway for it to be - * compilable by ngcc. - * - * If `to` is actually a path to a file then this will fail, which is what we want. - * - * @param from the file path from where to start trying to resolve this module - * @param to the module specifier of the dependency to resolve - * @returns the resolved path to the entry point directory of the import or null - * if it cannot be resolved. - */ - tryResolveEntryPoint(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath|null { - const entryPoint = this.tryResolve(from, `${to}/package.json` as PathSegment); - return entryPoint && AbsoluteFsPath.from(path.dirname(entryPoint)); - } - - /** - * Resolve the absolute path of a module from a particular starting point. - * - * @param from the file path from where to start trying to resolve this module - * @param to the module specifier of the dependency to resolve - * @returns an absolute path to the entry-point of the dependency or null if it could not be - * resolved. - */ - tryResolve(from: AbsoluteFsPath, to: PathSegment): AbsoluteFsPath|null { - try { - return AbsoluteFsPath.from(require.resolve(to, {paths: [from]})); - } catch (e) { - return null; - } - } - /** * Check whether the given statement is an import with a string literal module specifier. * @param stmt the statement node to check. diff --git a/packages/compiler-cli/ngcc/src/packages/module_resolver.ts b/packages/compiler-cli/ngcc/src/packages/module_resolver.ts new file mode 100644 index 0000000000..1f3e4e704b --- /dev/null +++ b/packages/compiler-cli/ngcc/src/packages/module_resolver.ts @@ -0,0 +1,280 @@ +/** + * @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 fs from 'fs'; + +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {PathMappings, isRelativePath} from '../utils'; + + +/** + * This is a very cut-down implementation of the TypeScript module resolution strategy. + * + * It is specific to the needs of ngcc and is not intended to be a drop-in replacement + * for the TS module resolver. It is used to compute the dependencies between entry-points + * that may be compiled by ngcc. + * + * The algorithm only finds `.js` files for internal/relative imports and paths to + * the folder containing the `package.json` of the entry-point for external imports. + * + * It can cope with nested `node_modules` folders and also supports `paths`/`baseUrl` + * configuration properties, as provided in a `ts.CompilerOptions` object. + */ +export class ModuleResolver { + private pathMappings: ProcessedPathMapping[]; + + constructor(pathMappings?: PathMappings, private relativeExtensions = ['.js', '/index.js']) { + this.pathMappings = pathMappings ? this.processPathMappings(pathMappings) : []; + } + + /** + * Resolve an absolute path for the `moduleName` imported into a file at `fromPath`. + * @param moduleName The name of the import to resolve. + * @param fromPath The path to the file containing the import. + * @returns A path to the resolved module or null if missing. + * Specifically: + * * the absolute path to the package.json of an external module + * * a JavaScript file of an internal module + * * null if none exists. + */ + resolveModuleImport(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null { + if (isRelativePath(moduleName)) { + return this.resolveAsRelativePath(moduleName, fromPath); + } else { + return this.pathMappings.length && this.resolveByPathMappings(moduleName, fromPath) || + this.resolveAsEntryPoint(moduleName, fromPath); + } + } + + /** + * Convert the `pathMappings` into a collection of `PathMapper` functions. + */ + private processPathMappings(pathMappings: PathMappings): ProcessedPathMapping[] { + const baseUrl = AbsoluteFsPath.from(pathMappings.baseUrl); + return Object.keys(pathMappings.paths).map(pathPattern => { + const matcher = splitOnStar(pathPattern); + const templates = pathMappings.paths[pathPattern].map(splitOnStar); + return {matcher, templates, baseUrl}; + }); + } + + /** + * Try to resolve a module name, as a relative path, from the `fromPath`. + * + * As it is relative, it only looks for files that end in one of the `relativeExtensions`. + * For example: `${moduleName}.js` or `${moduleName}/index.js`. + * If neither of these files exist then the method returns `null`. + */ + private resolveAsRelativePath(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null { + const resolvedPath = this.resolvePath( + AbsoluteFsPath.resolve(AbsoluteFsPath.dirname(fromPath), moduleName), + this.relativeExtensions); + return resolvedPath && new ResolvedRelativeModule(resolvedPath); + } + + /** + * Try to resolve the `moduleName`, by applying the computed `pathMappings` and + * then trying to resolve the mapped path as a relative or external import. + * + * Whether the mapped path is relative is defined as it being "below the `fromPath`" and not + * containing `node_modules`. + * + * If the mapped path is not relative but does not resolve to an external entry-point, then we + * check whether it would have resolved to a relative path, in which case it is marked as a + * "deep-import". + */ + private resolveByPathMappings(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null { + const mappedPaths = this.findMappedPaths(moduleName); + if (mappedPaths.length > 0) { + const packagePath = this.findPackagePath(fromPath); + if (packagePath !== null) { + for (const mappedPath of mappedPaths) { + const isRelative = + mappedPath.startsWith(packagePath) && !mappedPath.includes('node_modules'); + if (isRelative) { + return this.resolveAsRelativePath(mappedPath, fromPath); + } else if (this.isEntryPoint(mappedPath)) { + return new ResolvedExternalModule(mappedPath); + } else if (this.resolveAsRelativePath(mappedPath, fromPath)) { + return new ResolvedDeepImport(mappedPath); + } + } + } + } + return null; + } + + /** + * Try to resolve the `moduleName` as an external entry-point by searching the `node_modules` + * folders up the tree for a matching `.../node_modules/${moduleName}`. + * + * If a folder is found but the path does not contain a `package.json` then it is marked as a + * "deep-import". + */ + private resolveAsEntryPoint(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null { + let folder = fromPath; + while (folder !== '/') { + folder = AbsoluteFsPath.dirname(folder); + if (folder.endsWith('node_modules')) { + // Skip up if the folder already ends in node_modules + folder = AbsoluteFsPath.dirname(folder); + } + const modulePath = AbsoluteFsPath.resolve(folder, 'node_modules', moduleName); + if (this.isEntryPoint(modulePath)) { + return new ResolvedExternalModule(modulePath); + } else if (this.resolveAsRelativePath(modulePath, fromPath)) { + return new ResolvedDeepImport(modulePath); + } + } + return null; + } + + /** + * Attempt to resolve a `path` to a file by appending the provided `postFixes` + * to the `path` and checking if the file exists on disk. + * @returns An absolute path to the first matching existing file, or `null` if none exist. + */ + private resolvePath(path: string, postFixes: string[]): AbsoluteFsPath|null { + for (const postFix of postFixes) { + const testPath = path + postFix; + if (fs.existsSync(testPath)) { + return AbsoluteFsPath.from(testPath); + } + } + return null; + } + + /** + * Can we consider the given path as an entry-point to a package? + * + * This is achieved by checking for the existence of `${modulePath}/package.json`. + */ + private isEntryPoint(modulePath: AbsoluteFsPath): boolean { + return fs.existsSync(AbsoluteFsPath.join(modulePath, 'package.json')); + } + + /** + * Apply the `pathMappers` to the `moduleName` and return all the possible + * paths that match. + * + * The mapped path is computed for each template in `mapping.templates` by + * replacing the `matcher.prefix` and `matcher.postfix` strings in `path with the + * `template.prefix` and `template.postfix` strings. + */ + private findMappedPaths(moduleName: string): AbsoluteFsPath[] { + const matches = this.pathMappings.map(mapping => this.matchMapping(moduleName, mapping)); + + let bestMapping: ProcessedPathMapping|undefined; + let bestMatch: string|undefined; + + for (let index = 0; index < this.pathMappings.length; index++) { + const mapping = this.pathMappings[index]; + const match = matches[index]; + if (match !== null) { + // If this mapping had no wildcard then this must be a complete match. + if (!mapping.matcher.hasWildcard) { + bestMatch = match; + bestMapping = mapping; + break; + } + // The best matched mapping is the one with the longest prefix. + if (!bestMapping || mapping.matcher.prefix > bestMapping.matcher.prefix) { + bestMatch = match; + bestMapping = mapping; + } + } + } + + return (bestMapping && bestMatch) ? this.computeMappedTemplates(bestMapping, bestMatch) : []; + } + + /** + * Attempt to find a mapped path for the given `path` and a `mapping`. + * + * The `path` matches the `mapping` if if it starts with `matcher.prefix` and ends with + * `matcher.postfix`. + * + * @returns the wildcard segment of a matched `path`, or `null` if no match. + */ + private matchMapping(path: string, mapping: ProcessedPathMapping): string|null { + const {prefix, postfix, hasWildcard} = mapping.matcher; + if (path.startsWith(prefix) && path.endsWith(postfix)) { + return hasWildcard ? path.substring(prefix.length, path.length - postfix.length) : ''; + } + return null; + } + + /** + * Compute the candidate paths from the given mapping's templates using the matched + * string. + */ + private computeMappedTemplates(mapping: ProcessedPathMapping, match: string) { + return mapping.templates.map( + template => + AbsoluteFsPath.resolve(mapping.baseUrl, template.prefix + match + template.postfix)); + } + + /** + * Search up the folder tree for the first folder that contains `package.json` + * or `null` if none is found. + */ + private findPackagePath(path: AbsoluteFsPath): AbsoluteFsPath|null { + let folder = path; + while (folder !== '/') { + folder = AbsoluteFsPath.dirname(folder); + if (fs.existsSync(AbsoluteFsPath.join(folder, 'package.json'))) { + return folder; + } + } + return null; + } +} + +/** The result of resolving an import to a module. */ +export type ResolvedModule = ResolvedExternalModule | ResolvedRelativeModule | ResolvedDeepImport; + +/** + * A module that is external to the package doing the importing. + * In this case we capture the folder containing the entry-point. + */ +export class ResolvedExternalModule { + constructor(public entryPointPath: AbsoluteFsPath) {} +} + +/** + * A module that is relative to the module doing the importing, and so internal to the + * source module's package. + */ +export class ResolvedRelativeModule { + constructor(public modulePath: AbsoluteFsPath) {} +} + +/** + * A module that is external to the package doing the importing but pointing to a + * module that is deep inside a package, rather than to an entry-point of the package. + */ +export class ResolvedDeepImport { + constructor(public importPath: AbsoluteFsPath) {} +} + +function splitOnStar(str: string): PathMappingPattern { + const [prefix, postfix] = str.split('*', 2); + return {prefix, postfix: postfix || '', hasWildcard: postfix !== undefined}; +} + +interface ProcessedPathMapping { + baseUrl: AbsoluteFsPath; + matcher: PathMappingPattern; + templates: PathMappingPattern[]; +} + +interface PathMappingPattern { + prefix: string; + postfix: string; + hasWildcard: boolean; +} diff --git a/packages/compiler-cli/ngcc/src/utils.ts b/packages/compiler-cli/ngcc/src/utils.ts index cce404ad3d..9cb91a2c09 100644 --- a/packages/compiler-cli/ngcc/src/utils.ts +++ b/packages/compiler-cli/ngcc/src/utils.ts @@ -51,3 +51,17 @@ export function hasNameIdentifier(declaration: ts.Declaration): declaration is t const namedDeclaration: ts.Declaration&{name?: ts.Node} = declaration; return namedDeclaration.name !== undefined && ts.isIdentifier(namedDeclaration.name); } + +export type PathMappings = { + baseUrl: string, + paths: {[key: string]: string[]} +}; + +/** + * Test whether a path is "relative". + * + * Relative paths start with `/`, `./` or `../`; or are simply `.` or `..`. + */ +export function isRelativePath(path: string): boolean { + return /^\/|^\.\.?($|\/)/.test(path); +} diff --git a/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts b/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts index 0a6889bff3..cdf58f2359 100644 --- a/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts @@ -5,24 +5,18 @@ * 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 path from 'canonical-path'; import * as mockFs from 'mock-fs'; import * as ts from 'typescript'; -import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {DependencyHost} from '../../src/packages/dependency_host'; -const Module = require('module'); - -interface DepMap { - [path: string]: {resolved: string[], missing: string[]}; -} +import {ModuleResolver} from '../../src/packages/module_resolver'; const _ = AbsoluteFsPath.from; describe('DependencyHost', () => { let host: DependencyHost; - beforeEach(() => host = new DependencyHost()); + beforeEach(() => host = new DependencyHost(new ModuleResolver())); describe('getDependencies()', () => { beforeEach(createMockFileSystem); @@ -31,47 +25,36 @@ describe('DependencyHost', () => { it('should not generate a TS AST if the source does not contain any imports or re-exports', () => { spyOn(ts, 'createSourceFile'); - host.computeDependencies( - _('/no/imports/or/re-exports.js'), new Set(), new Set(), new Set()); + host.computeDependencies(_('/no/imports/or/re-exports/index.js')); expect(ts.createSourceFile).not.toHaveBeenCalled(); }); it('should resolve all the external imports of the source file', () => { - spyOn(host, 'tryResolveEntryPoint') - .and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/external/imports.js'), resolved, missing, deepImports); - expect(resolved.size).toBe(2); - expect(resolved.has('RESOLVED/path/to/x')).toBe(true); - expect(resolved.has('RESOLVED/path/to/y')).toBe(true); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/external/imports/index.js')); + expect(dependencies.size).toBe(2); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(0); + expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true); + expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true); }); it('should resolve all the external re-exports of the source file', () => { - spyOn(host, 'tryResolveEntryPoint') - .and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/external/re-exports.js'), resolved, missing, deepImports); - expect(resolved.size).toBe(2); - expect(resolved.has('RESOLVED/path/to/x')).toBe(true); - expect(resolved.has('RESOLVED/path/to/y')).toBe(true); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/external/re-exports/index.js')); + expect(dependencies.size).toBe(2); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(0); + expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true); + expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true); }); it('should capture missing external imports', () => { - spyOn(host, 'tryResolveEntryPoint') - .and.callFake( - (from: string, importPath: string) => - importPath === 'missing' ? null : `RESOLVED/${importPath}`); - spyOn(host, 'tryResolve').and.callFake(() => null); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/external/imports-missing.js'), resolved, missing, deepImports); - expect(resolved.size).toBe(1); - expect(resolved.has('RESOLVED/path/to/x')).toBe(true); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/external/imports-missing/index.js')); + + expect(dependencies.size).toBe(1); + expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true); expect(missing.size).toBe(1); expect(missing.has('missing')).toBe(true); expect(deepImports.size).toBe(0); @@ -81,125 +64,113 @@ describe('DependencyHost', () => { // This scenario verifies the behavior of the dependency analysis when an external import // is found that does not map to an entry-point but still exists on disk, i.e. a deep import. // Such deep imports are captured for diagnostics purposes. - const tryResolveEntryPoint = (from: string, importPath: string) => - importPath === 'deep/import' ? null : `RESOLVED/${importPath}`; - spyOn(host, 'tryResolveEntryPoint').and.callFake(tryResolveEntryPoint); - spyOn(host, 'tryResolve') - .and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/external/deep-import.js'), resolved, missing, deepImports); - expect(resolved.size).toBe(0); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/external/deep-import/index.js')); + + expect(dependencies.size).toBe(0); expect(missing.size).toBe(0); expect(deepImports.size).toBe(1); - expect(deepImports.has('deep/import')).toBe(true); + expect(deepImports.has('/node_modules/lib-1/deep/import')).toBe(true); }); it('should recurse into internal dependencies', () => { - spyOn(host, 'resolveInternal') - .and.callFake( - (from: string, importPath: string) => path.join('/internal', importPath + '.js')); - spyOn(host, 'tryResolveEntryPoint') - .and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`); - const getDependenciesSpy = spyOn(host, 'computeDependencies').and.callThrough(); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/internal/outer.js'), resolved, missing, deepImports); - expect(getDependenciesSpy) - .toHaveBeenCalledWith('/internal/outer.js', resolved, missing, deepImports); - expect(getDependenciesSpy) - .toHaveBeenCalledWith( - '/internal/inner.js', resolved, missing, deepImports, jasmine.any(Set)); - expect(resolved.size).toBe(1); - expect(resolved.has('RESOLVED/path/to/y')).toBe(true); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/internal/outer/index.js')); + + expect(dependencies.size).toBe(1); + expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(0); }); - it('should handle circular internal dependencies', () => { - spyOn(host, 'resolveInternal') - .and.callFake( - (from: string, importPath: string) => path.join('/internal', importPath + '.js')); - spyOn(host, 'tryResolveEntryPoint') - .and.callFake((from: string, importPath: string) => `RESOLVED/${importPath}`); - const resolved = new Set(); - const missing = new Set(); - const deepImports = new Set(); - host.computeDependencies(_('/internal/circular-a.js'), resolved, missing, deepImports); - expect(resolved.size).toBe(2); - expect(resolved.has('RESOLVED/path/to/x')).toBe(true); - expect(resolved.has('RESOLVED/path/to/y')).toBe(true); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/internal/circular-a/index.js')); + expect(dependencies.size).toBe(2); + expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true); + expect(dependencies.has(_('/node_modules/lib-1/sub-1'))).toBe(true); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(0); + }); + + it('should support `paths` alias mappings when resolving modules', () => { + host = new DependencyHost(new ModuleResolver({ + baseUrl: '/dist', + paths: { + '@app/*': ['*'], + '@lib/*/test': ['lib/*/test'], + } + })); + const {dependencies, missing, deepImports} = + host.computeDependencies(_('/path-alias/index.js')); + expect(dependencies.size).toBe(4); + expect(dependencies.has(_('/dist/components'))).toBe(true); + expect(dependencies.has(_('/dist/shared'))).toBe(true); + expect(dependencies.has(_('/dist/lib/shared/test'))).toBe(true); + expect(dependencies.has(_('/node_modules/lib-1'))).toBe(true); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(0); }); function createMockFileSystem() { mockFs({ - '/no/imports/or/re-exports.js': 'some text but no import-like statements', - '/external/imports.js': `import {X} from 'path/to/x';\nimport {Y} from 'path/to/y';`, - '/external/re-exports.js': `export {X} from 'path/to/x';\nexport {Y} from 'path/to/y';`, - '/external/imports-missing.js': `import {X} from 'path/to/x';\nimport {Y} from 'missing';`, - '/external/deep-import.js': `import {Y} from 'deep/import';`, - '/internal/outer.js': `import {X} from './inner';`, - '/internal/inner.js': `import {Y} from 'path/to/y';`, - '/internal/circular-a.js': `import {B} from './circular-b'; import {X} from 'path/to/x';`, - '/internal/circular-b.js': `import {A} from './circular-a'; import {Y} from 'path/to/y';`, + '/no/imports/or/re-exports/index.js': '// some text but no import-like statements', + '/no/imports/or/re-exports/package.json': '{"esm2015": "./index.js"}', + '/no/imports/or/re-exports/index.metadata.json': 'MOCK METADATA', + '/external/imports/index.js': `import {X} from 'lib-1';\nimport {Y} from 'lib-1/sub-1';`, + '/external/imports/package.json': '{"esm2015": "./index.js"}', + '/external/imports/index.metadata.json': 'MOCK METADATA', + '/external/re-exports/index.js': `export {X} from 'lib-1';\nexport {Y} from 'lib-1/sub-1';`, + '/external/re-exports/package.json': '{"esm2015": "./index.js"}', + '/external/re-exports/index.metadata.json': 'MOCK METADATA', + '/external/imports-missing/index.js': + `import {X} from 'lib-1';\nimport {Y} from 'missing';`, + '/external/imports-missing/package.json': '{"esm2015": "./index.js"}', + '/external/imports-missing/index.metadata.json': 'MOCK METADATA', + '/external/deep-import/index.js': `import {Y} from 'lib-1/deep/import';`, + '/external/deep-import/package.json': '{"esm2015": "./index.js"}', + '/external/deep-import/index.metadata.json': 'MOCK METADATA', + '/internal/outer/index.js': `import {X} from '../inner';`, + '/internal/outer/package.json': '{"esm2015": "./index.js"}', + '/internal/outer/index.metadata.json': 'MOCK METADATA', + '/internal/inner/index.js': `import {Y} from 'lib-1/sub-1'; export declare class X {}`, + '/internal/circular-a/index.js': + `import {B} from '../circular-b'; import {X} from '../circular-b'; export {Y} from 'lib-1/sub-1';`, + '/internal/circular-b/index.js': + `import {A} from '../circular-a'; import {Y} from '../circular-a'; export {X} from 'lib-1';`, + '/internal/circular-a/package.json': '{"esm2015": "./index.js"}', + '/internal/circular-a/index.metadata.json': 'MOCK METADATA', + '/re-directed/index.js': `import {Z} from 'lib-1/sub-2';`, + '/re-directed/package.json': '{"esm2015": "./index.js"}', + '/re-directed/index.metadata.json': 'MOCK METADATA', + '/path-alias/index.js': + `import {TestHelper} from '@app/components';\nimport {Service} from '@app/shared';\nimport {TestHelper} from '@lib/shared/test';\nimport {X} from 'lib-1';`, + '/path-alias/package.json': '{"esm2015": "./index.js"}', + '/path-alias/index.metadata.json': 'MOCK METADATA', + '/node_modules/lib-1/index.js': 'export declare class X {}', + '/node_modules/lib-1/package.json': '{"esm2015": "./index.js"}', + '/node_modules/lib-1/index.metadata.json': 'MOCK METADATA', + '/node_modules/lib-1/deep/import/index.js': 'export declare class DeepImport {}', + '/node_modules/lib-1/sub-1/index.js': 'export declare class Y {}', + '/node_modules/lib-1/sub-1/package.json': '{"esm2015": "./index.js"}', + '/node_modules/lib-1/sub-1/index.metadata.json': 'MOCK METADATA', + '/node_modules/lib-1/sub-2.js': `export * from './sub-2/sub-2';`, + '/node_modules/lib-1/sub-2/sub-2.js': `export declare class Z {}';`, + '/node_modules/lib-1/sub-2/package.json': '{"esm2015": "./sub-2.js"}', + '/node_modules/lib-1/sub-2/sub-2.metadata.json': 'MOCK METADATA', + '/dist/components/index.js': `class MyComponent {};`, + '/dist/components/package.json': '{"esm2015": "./index.js"}', + '/dist/components/index.metadata.json': 'MOCK METADATA', + '/dist/shared/index.js': `import {X} from 'lib-1';\nexport class Service {}`, + '/dist/shared/package.json': '{"esm2015": "./index.js"}', + '/dist/shared/index.metadata.json': 'MOCK METADATA', + '/dist/lib/shared/test/index.js': `export class TestHelper {}`, + '/dist/lib/shared/test/package.json': '{"esm2015": "./index.js"}', + '/dist/lib/shared/test/index.metadata.json': 'MOCK METADATA', }); } - }); - describe('resolveInternal', () => { - it('should resolve the dependency via `Module._resolveFilename`', () => { - spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH'); - const result = host.resolveInternal( - _('/SOURCE/PATH/FILE'), PathSegment.fromFsPath('../TARGET/PATH/FILE')); - expect(result).toEqual('/RESOLVED_PATH'); - }); - - it('should first resolve the `to` on top of the `from` directory', () => { - const resolveSpy = spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH'); - host.resolveInternal(_('/SOURCE/PATH/FILE'), PathSegment.fromFsPath('../TARGET/PATH/FILE')); - expect(resolveSpy) - .toHaveBeenCalledWith('/SOURCE/TARGET/PATH/FILE', jasmine.any(Object), false, undefined); - }); - }); - - describe('tryResolveExternal', () => { - it('should call `tryResolve`, appending `package.json` to the target path', () => { - const tryResolveSpy = spyOn(host, 'tryResolve').and.returnValue('/PATH/TO/RESOLVED'); - host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH')); - expect(tryResolveSpy).toHaveBeenCalledWith('/SOURCE_PATH', 'TARGET_PATH/package.json'); - }); - - it('should return the directory containing the result from `tryResolve', () => { - spyOn(host, 'tryResolve').and.returnValue('/PATH/TO/RESOLVED'); - expect(host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH'))) - .toEqual(_('/PATH/TO')); - }); - - it('should return null if `tryResolve` returns null', () => { - spyOn(host, 'tryResolve').and.returnValue(null); - expect(host.tryResolveEntryPoint(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH'))) - .toEqual(null); - }); - }); - - describe('tryResolve()', () => { - it('should resolve the dependency via `Module._resolveFilename`, passing the `from` path to the `paths` option', - () => { - const resolveSpy = spyOn(Module, '_resolveFilename').and.returnValue('/RESOLVED_PATH'); - const result = host.tryResolve(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH')); - expect(resolveSpy).toHaveBeenCalledWith('TARGET_PATH', jasmine.any(Object), false, { - paths: ['/SOURCE_PATH'] - }); - expect(result).toEqual(_('/RESOLVED_PATH')); - }); - - it('should return null if `Module._resolveFilename` throws an error', () => { - const resolveSpy = - spyOn(Module, '_resolveFilename').and.throwError(`Cannot find module 'TARGET_PATH'`); - const result = host.tryResolve(_('/SOURCE_PATH'), PathSegment.fromFsPath('TARGET_PATH')); - expect(result).toBe(null); - }); + function restoreRealFileSystem() { mockFs.restore(); } }); describe('isStringImportOrReexport', () => { @@ -257,6 +228,4 @@ describe('DependencyHost', () => { .toBe(false); }); }); - - function restoreRealFileSystem() { mockFs.restore(); } -}); +}); \ No newline at end of file diff --git a/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts b/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts index 1618ab8f0e..62639ed11c 100644 --- a/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts @@ -9,6 +9,7 @@ import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {DependencyHost} from '../../src/packages/dependency_host'; import {DependencyResolver, SortedEntryPointsInfo} from '../../src/packages/dependency_resolver'; import {EntryPoint} from '../../src/packages/entry_point'; +import {ModuleResolver} from '../../src/packages/module_resolver'; import {MockLogger} from '../helpers/mock_logger'; const _ = AbsoluteFsPath.from; @@ -17,7 +18,7 @@ describe('DependencyResolver', () => { let host: DependencyHost; let resolver: DependencyResolver; beforeEach(() => { - host = new DependencyHost(); + host = new DependencyHost(new ModuleResolver()); resolver = new DependencyResolver(new MockLogger(), host); }); describe('sortEntryPointsByDependency()', () => { diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts index 92967be770..15b615e87b 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts @@ -13,6 +13,7 @@ import {DependencyHost} from '../../src/packages/dependency_host'; import {DependencyResolver} from '../../src/packages/dependency_resolver'; import {EntryPoint} from '../../src/packages/entry_point'; import {EntryPointFinder} from '../../src/packages/entry_point_finder'; +import {ModuleResolver} from '../../src/packages/module_resolver'; import {MockLogger} from '../helpers/mock_logger'; const _ = AbsoluteFsPath.from; @@ -21,7 +22,7 @@ describe('findEntryPoints()', () => { let resolver: DependencyResolver; let finder: EntryPointFinder; beforeEach(() => { - resolver = new DependencyResolver(new MockLogger(), new DependencyHost()); + resolver = new DependencyResolver(new MockLogger(), new DependencyHost(new ModuleResolver())); spyOn(resolver, 'sortEntryPointsByDependency').and.callFake((entryPoints: EntryPoint[]) => { return {entryPoints, ignoredEntryPoints: [], ignoredDependencies: []}; }); diff --git a/packages/compiler-cli/ngcc/test/packages/module_resolver_spec.ts b/packages/compiler-cli/ngcc/test/packages/module_resolver_spec.ts new file mode 100644 index 0000000000..adc8ac33e7 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/packages/module_resolver_spec.ts @@ -0,0 +1,212 @@ +/** + * @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 mockFs from 'mock-fs'; + +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {ModuleResolver, ResolvedDeepImport, ResolvedExternalModule, ResolvedRelativeModule} from '../../src/packages/module_resolver'; + +const _ = AbsoluteFsPath.from; + +function createMockFileSystem() { + mockFs({ + '/libs': { + 'local-package': { + 'package.json': 'PACKAGE.JSON for local-package', + 'index.js': `import {X} from './x';`, + 'x.js': `export class X {}`, + 'sub-folder': { + 'index.js': `import {X} from '../x';`, + }, + 'node_modules': { + 'package-1': { + 'sub-folder': {'index.js': `export class Z {}`}, + 'package.json': 'PACKAGE.JSON for package-1', + }, + }, + }, + 'node_modules': { + 'package-2': { + 'package.json': 'PACKAGE.JSON for package-2', + 'node_modules': { + 'package-3': { + 'package.json': 'PACKAGE.JSON for package-3', + }, + }, + }, + }, + }, + '/dist': { + 'package-4': { + 'x.js': `export class X {}`, + 'package.json': 'PACKAGE.JSON for package-4', + 'sub-folder': {'index.js': `import {X} from '@shared/package-4/x';`}, + }, + 'sub-folder': { + 'package-4': { + 'package.json': 'PACKAGE.JSON for package-4', + }, + 'package-5': { + 'package.json': 'PACKAGE.JSON for package-5', + 'post-fix': { + 'package.json': 'PACKAGE.JSON for package-5/post-fix', + } + }, + } + }, + '/node_modules': { + 'top-package': { + 'package.json': 'PACKAGE.JSON for top-package', + } + } + }); +} + +function restoreRealFileSystem() { + mockFs.restore(); +} + +describe('ModuleResolver', () => { + beforeEach(createMockFileSystem); + afterEach(restoreRealFileSystem); + + describe('resolveModule()', () => { + describe('with relative paths', () => { + it('should resolve sibling, child and aunt modules', () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('./x', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedRelativeModule(_('/libs/local-package/x.js'))); + expect(resolver.resolveModuleImport('./sub-folder', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedRelativeModule(_('/libs/local-package/sub-folder/index.js'))); + expect(resolver.resolveModuleImport('../x', _('/libs/local-package/sub-folder/index.js'))) + .toEqual(new ResolvedRelativeModule(_('/libs/local-package/x.js'))); + }); + + it('should return `null` if the resolved module relative module does not exist', () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('./y', _('/libs/local-package/index.js'))).toBe(null); + }); + }); + + describe('with non-mapped external paths', () => { + it('should resolve to the package.json of a local node_modules package', () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('package-1', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1'))); + expect( + resolver.resolveModuleImport('package-1', _('/libs/local-package/sub-folder/index.js'))) + .toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1'))); + expect(resolver.resolveModuleImport('package-1', _('/libs/local-package/x.js'))) + .toEqual(new ResolvedExternalModule(_('/libs/local-package/node_modules/package-1'))); + }); + + it('should resolve to the package.json of a higher node_modules package', () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('package-2', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/libs/node_modules/package-2'))); + expect(resolver.resolveModuleImport('top-package', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/node_modules/top-package'))); + }); + + it('should return `null` if the package cannot be found', () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('missing-2', _('/libs/local-package/index.js'))) + .toBe(null); + }); + + it('should return `null` if the package is not accessible because it is in a inner node_modules package', + () => { + const resolver = new ModuleResolver(); + expect(resolver.resolveModuleImport('package-3', _('/libs/local-package/index.js'))) + .toBe(null); + }); + + it('should identify deep imports into an external module', () => { + const resolver = new ModuleResolver(); + expect( + resolver.resolveModuleImport('package-1/sub-folder', _('/libs/local-package/index.js'))) + .toEqual( + new ResolvedDeepImport(_('/libs/local-package/node_modules/package-1/sub-folder'))); + }); + }); + + describe('with mapped path external modules', () => { + it('should resolve to the package.json of simple mapped packages', () => { + const resolver = + new ModuleResolver({baseUrl: '/dist', paths: {'*': ['*', 'sub-folder/*']}}); + + expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/package-4'))); + + expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5'))); + }); + + it('should select the best match by the length of prefix before the *', () => { + const resolver = new ModuleResolver({ + baseUrl: '/dist', + paths: { + '@lib/*': ['*'], + '@lib/sub-folder/*': ['*'], + } + }); + + // We should match the second path (e.g. `'@lib/sub-folder/*'`), which will actually map to + // `*` and so the final resolved path will not include the `sub-folder` segment. + expect(resolver.resolveModuleImport( + '@lib/sub-folder/package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/package-4'))); + }); + + it('should follow the ordering of `paths` when matching mapped packages', () => { + let resolver: ModuleResolver; + + resolver = new ModuleResolver({baseUrl: '/dist', paths: {'*': ['*', 'sub-folder/*']}}); + expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/package-4'))); + + resolver = new ModuleResolver({baseUrl: '/dist', paths: {'*': ['sub-folder/*', '*']}}); + expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-4'))); + }); + + it('should resolve packages when the path mappings have post-fixes', () => { + const resolver = + new ModuleResolver({baseUrl: '/dist', paths: {'*': ['sub-folder/*/post-fix']}}); + expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5/post-fix'))); + }); + + it('should match paths against complex path matchers', () => { + const resolver = + new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*': ['sub-folder/*']}}); + expect(resolver.resolveModuleImport('@shared/package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-4'))); + expect(resolver.resolveModuleImport('package-5', _('/libs/local-package/index.js'))) + .toBe(null); + }); + + it('should resolve path as "relative" if the mapped path is inside the current package', + () => { + const resolver = new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*': ['*']}}); + expect(resolver.resolveModuleImport( + '@shared/package-4/x', _('/dist/package-4/sub-folder/index.js'))) + .toEqual(new ResolvedRelativeModule(_('/dist/package-4/x.js'))); + }); + + it('should resolve paths where the wildcard matches more than one path segment', () => { + const resolver = + new ModuleResolver({baseUrl: '/dist', paths: {'@shared/*/post-fix': ['*/post-fix']}}); + expect( + resolver.resolveModuleImport( + '@shared/sub-folder/package-5/post-fix', _('/dist/package-4/sub-folder/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/sub-folder/package-5/post-fix'))); + }); + }); + }); +}); diff --git a/packages/compiler-cli/ngcc/test/utils_spec.ts b/packages/compiler-cli/ngcc/test/utils_spec.ts new file mode 100644 index 0000000000..4b44d7e6d2 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/utils_spec.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 {isRelativePath} from '../src/utils'; + +describe('isRelativePath()', () => { + it('should return true for relative paths', () => { + expect(isRelativePath('.')).toBe(true); + expect(isRelativePath('..')).toBe(true); + expect(isRelativePath('./')).toBe(true); + expect(isRelativePath('../')).toBe(true); + expect(isRelativePath('./abc/xyz')).toBe(true); + expect(isRelativePath('../abc/xyz')).toBe(true); + }); + + it('should return true for absolute paths', () => { + expect(isRelativePath('/')).toBe(true); + expect(isRelativePath('/abc/xyz')).toBe(true); + }); + + it('should return false for other paths', () => { + expect(isRelativePath('abc')).toBe(false); + expect(isRelativePath('abc/xyz')).toBe(false); + expect(isRelativePath('.abc')).toBe(false); + expect(isRelativePath('..abc')).toBe(false); + expect(isRelativePath('@abc')).toBe(false); + expect(isRelativePath('.abc/xyz')).toBe(false); + expect(isRelativePath('..abc/xyz')).toBe(false); + expect(isRelativePath('@abc/xyz')).toBe(false); + }); +});