refactor(ivy): ngcc - implement new module resolver (#29643)
When working out the dependencies between entry-points ngcc must parse the import statements and then resolve the import path to the actual file. This is complicated because module resolution is not trivial. Previously ngcc used the node.js `require.resolve`, with some hacking to resolve modules. This change refactors the `DependencyHost` to use a new custom `ModuleResolver`, which is optimized for this use case. Moreover, because we are in full control of the resolution, we can support TS `paths` aliases, where not all imports come from `node_modules`. This is the case in some CLI projects where there are compiled libraries that are stored locally in a `dist` folder. See //FW-1210. PR Close #29643
This commit is contained in:

committed by
Andrew Kushnir

parent
eef4ca5dd3
commit
4a2405929c
@ -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);
|
||||
|
@ -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<AbsoluteFsPath> = new Set(),
|
||||
missing: Set<PathSegment> = new Set(), deepImports: Set<PathSegment> = new Set(),
|
||||
internal: Set<AbsoluteFsPath> = new Set()): {
|
||||
dependencies: Set<AbsoluteFsPath>,
|
||||
missing: Set<PathSegment>,
|
||||
deepImports: Set<PathSegment>
|
||||
} {
|
||||
missing: Set<string> = new Set(), deepImports: Set<string> = new Set(),
|
||||
alreadySeen: Set<AbsoluteFsPath> = new Set()):
|
||||
{dependencies: Set<AbsoluteFsPath>, missing: Set<string>, deepImports: Set<string>} {
|
||||
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.
|
||||
|
280
packages/compiler-cli/ngcc/src/packages/module_resolver.ts
Normal file
280
packages/compiler-cli/ngcc/src/packages/module_resolver.ts
Normal file
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user