diff --git a/packages/compiler-cli/ngcc/index.ts b/packages/compiler-cli/ngcc/index.ts index dc63ad76b857..8b131672cae4 100644 --- a/packages/compiler-cli/ngcc/index.ts +++ b/packages/compiler-cli/ngcc/index.ts @@ -12,6 +12,7 @@ import {EntryPointJsonProperty, EntryPointPackageJson} from './src/packages/entr export {ConsoleLogger, LogLevel} from './src/logging/console_logger'; export {Logger} from './src/logging/logger'; export {NgccOptions, mainNgcc as process} from './src/main'; +export {PathMappings} from './src/utils'; export function hasBeenProcessed(packageJson: object, format: string) { // We are wrapping this function to hide the internal types. diff --git a/packages/compiler-cli/ngcc/main-ngcc.ts b/packages/compiler-cli/ngcc/main-ngcc.ts index b4ec014e3728..e47904e58d9f 100644 --- a/packages/compiler-cli/ngcc/main-ngcc.ts +++ b/packages/compiler-cli/ngcc/main-ngcc.ts @@ -6,9 +6,9 @@ * 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 yargs from 'yargs'; +import {AbsoluteFsPath} from '../src/ngtsc/path'; import {mainNgcc} from './src/main'; import {ConsoleLogger, LogLevel} from './src/logging/console_logger'; @@ -56,7 +56,7 @@ if (require.main === module) { 'The formats option (-f/--formats) has been removed. Consider the properties option (-p/--properties) instead.'); process.exit(1); } - const baseSourcePath = path.resolve(options['s'] || './node_modules'); + const baseSourcePath = AbsoluteFsPath.resolve(options['s'] || './node_modules'); const propertiesToConsider: string[] = options['p']; const targetEntryPointPath = options['t'] ? options['t'] : undefined; const compileAllFormats = !options['first-only']; diff --git a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts index 65f491f0709f..1a24bfab42e0 100644 --- a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts +++ b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts @@ -6,9 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ import {ConstantPool} from '@angular/compiler'; -import {NOOP_PERF_RECORDER} from '@angular/compiler-cli/src/ngtsc/perf'; -import * as path from 'canonical-path'; -import * as fs from 'fs'; import * as ts from 'typescript'; import {BaseDefDecoratorHandler, ComponentDecoratorHandler, DirectiveDecoratorHandler, InjectableDecoratorHandler, NgModuleDecoratorHandler, PipeDecoratorHandler, ReferencesRegistry, ResourceLoader} from '../../../src/ngtsc/annotations'; @@ -19,6 +16,7 @@ import {PartialEvaluator} from '../../../src/ngtsc/partial_evaluator'; import {AbsoluteFsPath, LogicalFileSystem} from '../../../src/ngtsc/path'; import {LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver} from '../../../src/ngtsc/scope'; import {CompileResult, DecoratorHandler, DetectResult, HandlerPrecedence} from '../../../src/ngtsc/transform'; +import {FileSystem} from '../file_system/file_system'; import {DecoratedClass} from '../host/decorated_class'; import {NgccReflectionHost} from '../host/ngcc_host'; import {isDefined} from '../utils'; @@ -53,11 +51,12 @@ export interface MatchingHandler { * Simple class that resolves and loads files directly from the filesystem. */ class NgccResourceLoader implements ResourceLoader { + constructor(private fs: FileSystem) {} canPreload = false; preload(): undefined|Promise { throw new Error('Not implemented.'); } - load(url: string): string { return fs.readFileSync(url, 'utf8'); } + load(url: string): string { return this.fs.readFile(AbsoluteFsPath.resolve(url)); } resolve(url: string, containingFile: string): string { - return path.resolve(path.dirname(containingFile), url); + return AbsoluteFsPath.resolve(AbsoluteFsPath.dirname(AbsoluteFsPath.from(containingFile)), url); } } @@ -65,7 +64,7 @@ class NgccResourceLoader implements ResourceLoader { * This Analyzer will analyze the files that have decorated classes that need to be transformed. */ export class DecorationAnalyzer { - resourceManager = new NgccResourceLoader(); + resourceManager = new NgccResourceLoader(this.fs); metaRegistry = new LocalMetadataRegistry(); dtsMetaReader = new DtsMetadataReader(this.typeChecker, this.reflectionHost); fullMetaReader = new CompoundMetadataReader([this.metaRegistry, this.dtsMetaReader]); @@ -73,8 +72,8 @@ export class DecorationAnalyzer { new LocalIdentifierStrategy(), new AbsoluteModuleStrategy(this.program, this.typeChecker, this.options, this.host), // TODO(alxhub): there's no reason why ngcc needs the "logical file system" logic here, as ngcc - // projects only ever have one rootDir. Instead, ngcc should just switch its emitted imort based - // on whether a bestGuessOwningModule is present in the Reference. + // projects only ever have one rootDir. Instead, ngcc should just switch its emitted import + // based on whether a bestGuessOwningModule is present in the Reference. new LogicalProjectStrategy(this.typeChecker, new LogicalFileSystem(this.rootDirs)), ]); dtsModuleScopeResolver = @@ -110,7 +109,7 @@ export class DecorationAnalyzer { ]; constructor( - private program: ts.Program, private options: ts.CompilerOptions, + private fs: FileSystem, private program: ts.Program, private options: ts.CompilerOptions, private host: ts.CompilerHost, private typeChecker: ts.TypeChecker, private reflectionHost: NgccReflectionHost, private referencesRegistry: ReferencesRegistry, private rootDirs: AbsoluteFsPath[], private isCore: boolean) {} diff --git a/packages/compiler-cli/ngcc/src/analysis/private_declarations_analyzer.ts b/packages/compiler-cli/ngcc/src/analysis/private_declarations_analyzer.ts index ff7d3ce2ccc1..72698b55824c 100644 --- a/packages/compiler-cli/ngcc/src/analysis/private_declarations_analyzer.ts +++ b/packages/compiler-cli/ngcc/src/analysis/private_declarations_analyzer.ts @@ -7,6 +7,7 @@ */ import * as ts from 'typescript'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {Declaration} from '../../../src/ngtsc/reflection'; import {NgccReflectionHost} from '../host/ngcc_host'; import {hasNameIdentifier, isDefined} from '../utils'; @@ -14,8 +15,8 @@ import {NgccReferencesRegistry} from './ngcc_references_registry'; export interface ExportInfo { identifier: string; - from: string; - dtsFrom?: string|null; + from: AbsoluteFsPath; + dtsFrom?: AbsoluteFsPath|null; alias?: string|null; } export type PrivateDeclarationsAnalyses = ExportInfo[]; @@ -93,11 +94,12 @@ export class PrivateDeclarationsAnalyzer { }); return Array.from(privateDeclarations.keys()).map(id => { - const from = id.getSourceFile().fileName; + const from = AbsoluteFsPath.fromSourceFile(id.getSourceFile()); const declaration = privateDeclarations.get(id) !; const alias = exportAliasDeclarations.get(id) || null; const dtsDeclaration = this.host.getDtsDeclaration(declaration.node); - const dtsFrom = dtsDeclaration && dtsDeclaration.getSourceFile().fileName; + const dtsFrom = + dtsDeclaration && AbsoluteFsPath.fromSourceFile(dtsDeclaration.getSourceFile()); return {identifier: id.text, from, dtsFrom, alias}; }); diff --git a/packages/compiler-cli/ngcc/src/dependencies/dependency_host.ts b/packages/compiler-cli/ngcc/src/dependencies/dependency_host.ts new file mode 100644 index 000000000000..b2210eb39c7f --- /dev/null +++ b/packages/compiler-cli/ngcc/src/dependencies/dependency_host.ts @@ -0,0 +1,19 @@ +/** + * @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 {AbsoluteFsPath} from '../../../src/ngtsc/path'; + +export interface DependencyHost { + findDependencies(entryPointPath: AbsoluteFsPath): DependencyInfo; +} + +export interface DependencyInfo { + dependencies: Set; + missing: Set; + deepImports: Set; +} diff --git a/packages/compiler-cli/ngcc/src/packages/dependency_resolver.ts b/packages/compiler-cli/ngcc/src/dependencies/dependency_resolver.ts similarity index 83% rename from packages/compiler-cli/ngcc/src/packages/dependency_resolver.ts rename to packages/compiler-cli/ngcc/src/dependencies/dependency_resolver.ts index 59737ed322f9..f05424c7f02a 100644 --- a/packages/compiler-cli/ngcc/src/packages/dependency_resolver.ts +++ b/packages/compiler-cli/ngcc/src/dependencies/dependency_resolver.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {resolve} from 'canonical-path'; import {DepGraph} from 'dependency-graph'; import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {Logger} from '../logging/logger'; +import {EntryPoint, EntryPointJsonProperty, getEntryPointFormat} from '../packages/entry_point'; import {DependencyHost} from './dependency_host'; -import {EntryPoint, EntryPointJsonProperty, getEntryPointFormat} from './entry_point'; + /** @@ -48,6 +48,11 @@ export interface IgnoredDependency { dependencyPath: string; } +export interface DependencyDiagnostics { + invalidEntryPoints: InvalidEntryPoint[]; + ignoredDependencies: IgnoredDependency[]; +} + /** * A list of entry-points, sorted by their dependencies. * @@ -57,11 +62,7 @@ export interface IgnoredDependency { * Some entry points or their dependencies may be have been ignored. These are captured for * diagnostic purposes in `invalidEntryPoints` and `ignoredDependencies` respectively. */ -export interface SortedEntryPointsInfo { - entryPoints: EntryPoint[]; - invalidEntryPoints: InvalidEntryPoint[]; - ignoredDependencies: IgnoredDependency[]; -} +export interface SortedEntryPointsInfo extends DependencyDiagnostics { entryPoints: EntryPoint[]; } /** * A class that resolves dependencies between entry-points. @@ -77,12 +78,17 @@ export class DependencyResolver { */ sortEntryPointsByDependency(entryPoints: EntryPoint[], target?: EntryPoint): SortedEntryPointsInfo { - const {invalidEntryPoints, ignoredDependencies, graph} = this.createDependencyInfo(entryPoints); + const {invalidEntryPoints, ignoredDependencies, graph} = + this.computeDependencyGraph(entryPoints); let sortedEntryPointNodes: string[]; if (target) { - sortedEntryPointNodes = graph.dependenciesOf(target.path); - sortedEntryPointNodes.push(target.path); + if (target.compiledByAngular) { + sortedEntryPointNodes = graph.dependenciesOf(target.path); + sortedEntryPointNodes.push(target.path); + } else { + sortedEntryPointNodes = []; + } } else { sortedEntryPointNodes = graph.overallOrder(); } @@ -100,18 +106,20 @@ export class DependencyResolver { * The graph only holds entry-points that ngcc cares about and whose dependencies * (direct and transitive) all exist. */ - private createDependencyInfo(entryPoints: EntryPoint[]) { + private computeDependencyGraph(entryPoints: EntryPoint[]): DependencyGraph { const invalidEntryPoints: InvalidEntryPoint[] = []; const ignoredDependencies: IgnoredDependency[] = []; const graph = new DepGraph(); - // Add the entry points to the graph as nodes - entryPoints.forEach(entryPoint => graph.addNode(entryPoint.path, entryPoint)); + const angularEntryPoints = entryPoints.filter(entryPoint => entryPoint.compiledByAngular); + + // Add the Angular compiled entry points to the graph as nodes + angularEntryPoints.forEach(entryPoint => graph.addNode(entryPoint.path, entryPoint)); // Now add the dependencies between them - entryPoints.forEach(entryPoint => { + angularEntryPoints.forEach(entryPoint => { const entryPointPath = getEntryPointPath(entryPoint); - const {dependencies, missing, deepImports} = this.host.computeDependencies(entryPointPath); + const {dependencies, missing, deepImports} = this.host.findDependencies(entryPointPath); if (missing.size > 0) { // This entry point has dependencies that are missing @@ -162,8 +170,12 @@ function getEntryPointPath(entryPoint: EntryPoint): AbsoluteFsPath { if (format === 'esm2015' || format === 'esm5') { const formatPath = entryPoint.packageJson[property] !; - return AbsoluteFsPath.from(resolve(entryPoint.path, formatPath)); + return AbsoluteFsPath.resolve(entryPoint.path, formatPath); } } throw new Error(`There is no format with import statements in '${entryPoint.path}' entry-point.`); } + +interface DependencyGraph extends DependencyDiagnostics { + graph: DepGraph; +} diff --git a/packages/compiler-cli/ngcc/src/dependencies/esm_dependency_host.ts b/packages/compiler-cli/ngcc/src/dependencies/esm_dependency_host.ts new file mode 100644 index 000000000000..33c763069870 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/dependencies/esm_dependency_host.ts @@ -0,0 +1,117 @@ +/** + * @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 {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; +import {DependencyHost, DependencyInfo} from './dependency_host'; +import {ModuleResolver, ResolvedDeepImport, ResolvedRelativeModule} from './module_resolver'; + + +/** + * Helper functions for computing dependencies. + */ +export class EsmDependencyHost implements DependencyHost { + constructor(private fs: FileSystem, private moduleResolver: ModuleResolver) {} + + /** + * Find all the dependencies for the entry-point at the given path. + * + * @param entryPointPath The absolute path to the JavaScript file that represents an entry-point. + * @returns Information about the dependencies of the entry-point, including those that were + * missing or deep imports into other entry-points. + */ + findDependencies(entryPointPath: AbsoluteFsPath): DependencyInfo { + const dependencies = new Set(); + const missing = new Set(); + const deepImports = new Set(); + const alreadySeen = new Set(); + this.recursivelyFindDependencies( + entryPointPath, dependencies, missing, deepImports, alreadySeen); + return {dependencies, missing, deepImports}; + } + + /** + * Compute the dependencies of the given file. + * + * @param file An absolute path to the file whose dependencies we want to get. + * @param dependencies A set that will have the absolute paths of resolved entry points added to + * it. + * @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 alreadySeen A set that is used to track internal dependencies to prevent getting stuck + * in a + * circular dependency loop. + */ + private recursivelyFindDependencies( + file: AbsoluteFsPath, dependencies: Set, missing: Set, + deepImports: Set, alreadySeen: Set): void { + const fromContents = this.fs.readFile(file); + if (!this.hasImportOrReexportStatements(fromContents)) { + return; + } + + // Parse the source into a TypeScript AST and then walk it looking for imports and re-exports. + const sf = + ts.createSourceFile(file, fromContents, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS); + sf.statements + // filter out statements that are not imports or reexports + .filter(this.isStringImportOrReexport) + // 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 => { + const resolvedModule = this.moduleResolver.resolveModuleImport(importPath, file); + if (resolvedModule) { + if (resolvedModule instanceof ResolvedRelativeModule) { + const internalDependency = resolvedModule.modulePath; + if (!alreadySeen.has(internalDependency)) { + alreadySeen.add(internalDependency); + this.recursivelyFindDependencies( + internalDependency, dependencies, missing, deepImports, alreadySeen); + } + } else { + if (resolvedModule instanceof ResolvedDeepImport) { + deepImports.add(resolvedModule.importPath); + } else { + dependencies.add(resolvedModule.entryPointPath); + } + } + } else { + missing.add(importPath); + } + }); + } + + /** + * Check whether the given statement is an import with a string literal module specifier. + * @param stmt the statement node to check. + * @returns true if the statement is an import with a string literal module specifier. + */ + isStringImportOrReexport(stmt: ts.Statement): stmt is ts.ImportDeclaration& + {moduleSpecifier: ts.StringLiteral} { + return ts.isImportDeclaration(stmt) || + ts.isExportDeclaration(stmt) && !!stmt.moduleSpecifier && + ts.isStringLiteral(stmt.moduleSpecifier); + } + + /** + * Check whether a source file needs to be parsed for imports. + * This is a performance short-circuit, which saves us from creating + * a TypeScript AST unnecessarily. + * + * @param source The content of the source file to check. + * + * @returns false if there are definitely no import or re-export statements + * in this file, true otherwise. + */ + hasImportOrReexportStatements(source: string): boolean { + return /(import|export)\s.+from/.test(source); + } +} diff --git a/packages/compiler-cli/ngcc/src/dependencies/module_resolver.ts b/packages/compiler-cli/ngcc/src/dependencies/module_resolver.ts new file mode 100644 index 000000000000..e8c12e361da6 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/dependencies/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 {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; +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(private fs: FileSystem, 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: AbsoluteFsPath, postFixes: string[]): AbsoluteFsPath|null { + for (const postFix of postFixes) { + const testPath = AbsoluteFsPath.fromUnchecked(path + postFix); + if (this.fs.exists(testPath)) { + return 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 this.fs.exists(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 (this.fs.exists(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/file_system/file_system.ts b/packages/compiler-cli/ngcc/src/file_system/file_system.ts new file mode 100644 index 000000000000..9c9653b48b71 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/file_system/file_system.ts @@ -0,0 +1,38 @@ +/** + * @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 {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; + +/** + * A basic interface to abstract the underlying file-system. + * + * This makes it easier to provide mock file-systems in unit tests, + * but also to create clever file-systems that have features such as caching. + */ +export interface FileSystem { + exists(path: AbsoluteFsPath): boolean; + readFile(path: AbsoluteFsPath): string; + writeFile(path: AbsoluteFsPath, data: string): void; + readdir(path: AbsoluteFsPath): PathSegment[]; + lstat(path: AbsoluteFsPath): FileStats; + stat(path: AbsoluteFsPath): FileStats; + pwd(): AbsoluteFsPath; + copyFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void; + moveFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void; + ensureDir(path: AbsoluteFsPath): void; +} + +/** + * Information about an object in the FileSystem. + * This is analogous to the `fs.Stats` class in Node.js. + */ +export interface FileStats { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +} diff --git a/packages/compiler-cli/ngcc/src/file_system/node_js_file_system.ts b/packages/compiler-cli/ngcc/src/file_system/node_js_file_system.ts new file mode 100644 index 000000000000..7f821c398669 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/file_system/node_js_file_system.ts @@ -0,0 +1,30 @@ +/** + * @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 {cp, mkdir, mv} from 'shelljs'; +import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; +import {FileSystem} from './file_system'; + +/** + * A wrapper around the Node.js file-system (i.e the `fs` package). + */ +export class NodeJSFileSystem implements FileSystem { + exists(path: AbsoluteFsPath): boolean { return fs.existsSync(path); } + readFile(path: AbsoluteFsPath): string { return fs.readFileSync(path, 'utf8'); } + writeFile(path: AbsoluteFsPath, data: string): void { + return fs.writeFileSync(path, data, 'utf8'); + } + readdir(path: AbsoluteFsPath): PathSegment[] { return fs.readdirSync(path) as PathSegment[]; } + lstat(path: AbsoluteFsPath): fs.Stats { return fs.lstatSync(path); } + stat(path: AbsoluteFsPath): fs.Stats { return fs.statSync(path); } + pwd() { return AbsoluteFsPath.fromUnchecked(process.cwd()); } + copyFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void { cp(from, to); } + moveFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void { mv(from, to); } + ensureDir(path: AbsoluteFsPath): void { mkdir('-p', path); } +} diff --git a/packages/compiler-cli/ngcc/src/main.ts b/packages/compiler-cli/ngcc/src/main.ts index 35cec79ba763..c69424fa9144 100644 --- a/packages/compiler-cli/ngcc/src/main.ts +++ b/packages/compiler-cli/ngcc/src/main.ts @@ -5,27 +5,25 @@ * 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 {resolve} from 'canonical-path'; -import {readFileSync} from 'fs'; - import {AbsoluteFsPath} from '../../src/ngtsc/path'; +import {DependencyResolver} from './dependencies/dependency_resolver'; +import {EsmDependencyHost} from './dependencies/esm_dependency_host'; +import {ModuleResolver} from './dependencies/module_resolver'; +import {FileSystem} from './file_system/file_system'; +import {NodeJSFileSystem} from './file_system/node_js_file_system'; import {ConsoleLogger, LogLevel} from './logging/console_logger'; import {Logger} from './logging/logger'; import {hasBeenProcessed, markAsProcessed} from './packages/build_marker'; -import {DependencyHost} from './packages/dependency_host'; -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 {Transformer} from './packages/transformer'; +import {PathMappings} from './utils'; import {FileWriter} from './writing/file_writer'; import {InPlaceFileWriter} from './writing/in_place_file_writer'; import {NewEntryPointFileWriter} from './writing/new_entry_point_file_writer'; - - /** * The options to configure the ngcc compiler. */ @@ -57,6 +55,11 @@ export interface NgccOptions { * Provide a logger that will be called with log messages. */ logger?: Logger; + /** + * Paths mapping configuration (`paths` and `baseUrl`), as found in `ts.CompilerOptions`. + * These are used to resolve paths to locally built Angular libraries. + */ + pathMappings?: PathMappings; } const SUPPORTED_FORMATS: EntryPointFormat[] = ['esm5', 'esm2015']; @@ -69,44 +72,33 @@ const SUPPORTED_FORMATS: EntryPointFormat[] = ['esm5', 'esm2015']; * * @param options The options telling ngcc what to compile and how. */ -export function mainNgcc({basePath, targetEntryPointPath, - propertiesToConsider = SUPPORTED_FORMAT_PROPERTIES, - compileAllFormats = true, createNewEntryPointFormats = false, - logger = new ConsoleLogger(LogLevel.info)}: NgccOptions): void { - const transformer = new Transformer(logger, basePath); - const host = new DependencyHost(); +export function mainNgcc( + {basePath, targetEntryPointPath, propertiesToConsider = SUPPORTED_FORMAT_PROPERTIES, + compileAllFormats = true, createNewEntryPointFormats = false, + logger = new ConsoleLogger(LogLevel.info), pathMappings}: NgccOptions): void { + const fs = new NodeJSFileSystem(); + const transformer = new Transformer(fs, logger); + const moduleResolver = new ModuleResolver(fs, pathMappings); + const host = new EsmDependencyHost(fs, moduleResolver); const resolver = new DependencyResolver(logger, host); - const finder = new EntryPointFinder(logger, resolver); - const fileWriter = getFileWriter(createNewEntryPointFormats); + const finder = new EntryPointFinder(fs, logger, resolver); + const fileWriter = getFileWriter(fs, createNewEntryPointFormats); - const absoluteTargetEntryPointPath = targetEntryPointPath ? - AbsoluteFsPath.from(resolve(basePath, targetEntryPointPath)) : - undefined; + const absoluteTargetEntryPointPath = + targetEntryPointPath ? AbsoluteFsPath.resolve(basePath, targetEntryPointPath) : undefined; if (absoluteTargetEntryPointPath && hasProcessedTargetEntryPoint( - absoluteTargetEntryPointPath, propertiesToConsider, compileAllFormats)) { + fs, absoluteTargetEntryPointPath, propertiesToConsider, compileAllFormats)) { logger.info('The target entry-point has already been processed'); return; } - const {entryPoints} = - finder.findEntryPoints(AbsoluteFsPath.from(basePath), absoluteTargetEntryPointPath); - - if (absoluteTargetEntryPointPath && entryPoints.every(entryPoint => { - return entryPoint.path !== absoluteTargetEntryPointPath; - })) { - // If we get here, then the requested entry-point did not contain anything compiled by - // the old Angular compiler. Therefore there is nothing for ngcc to do. - // So mark all formats in this entry-point as processed so that clients of ngcc can avoid - // triggering ngcc for this entry-point in the future. - const packageJsonPath = - AbsoluteFsPath.from(resolve(absoluteTargetEntryPointPath, 'package.json')); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - propertiesToConsider.forEach(formatProperty => { - if (packageJson[formatProperty]) - markAsProcessed(packageJson, packageJsonPath, formatProperty as EntryPointJsonProperty); - }); + const {entryPoints} = finder.findEntryPoints( + AbsoluteFsPath.from(basePath), absoluteTargetEntryPointPath, pathMappings); + + if (absoluteTargetEntryPointPath && entryPoints.length === 0) { + markNonAngularPackageAsProcessed(fs, absoluteTargetEntryPointPath, propertiesToConsider); return; } @@ -116,7 +108,8 @@ export function mainNgcc({basePath, targetEntryPointPath, const compiledFormats = new Set(); const entryPointPackageJson = entryPoint.packageJson; - const entryPointPackageJsonPath = AbsoluteFsPath.from(resolve(entryPoint.path, 'package.json')); + const entryPointPackageJsonPath = + AbsoluteFsPath.fromUnchecked(`${entryPoint.path}/package.json`); const hasProcessedDts = hasBeenProcessed(entryPointPackageJson, 'typings'); @@ -141,7 +134,8 @@ export function mainNgcc({basePath, targetEntryPointPath, // the property as processed even if its underlying format has been built already. if (!compiledFormats.has(formatPath) && (compileAllFormats || isFirstFormat)) { const bundle = makeEntryPointBundle( - entryPoint.path, formatPath, entryPoint.typings, isCore, property, format, processDts); + fs, entryPoint.path, formatPath, entryPoint.typings, isCore, property, format, + processDts, pathMappings); if (bundle) { logger.info(`Compiling ${entryPoint.name} : ${property} as ${format}`); const transformedFiles = transformer.transform(bundle); @@ -158,9 +152,9 @@ export function mainNgcc({basePath, targetEntryPointPath, // Either this format was just compiled or its underlying format was compiled because of a // previous property. if (compiledFormats.has(formatPath)) { - markAsProcessed(entryPointPackageJson, entryPointPackageJsonPath, property); + markAsProcessed(fs, entryPointPackageJson, entryPointPackageJsonPath, property); if (processDts) { - markAsProcessed(entryPointPackageJson, entryPointPackageJsonPath, 'typings'); + markAsProcessed(fs, entryPointPackageJson, entryPointPackageJsonPath, 'typings'); } } } @@ -172,14 +166,15 @@ export function mainNgcc({basePath, targetEntryPointPath, }); } -function getFileWriter(createNewEntryPointFormats: boolean): FileWriter { - return createNewEntryPointFormats ? new NewEntryPointFileWriter() : new InPlaceFileWriter(); +function getFileWriter(fs: FileSystem, createNewEntryPointFormats: boolean): FileWriter { + return createNewEntryPointFormats ? new NewEntryPointFileWriter(fs) : new InPlaceFileWriter(fs); } function hasProcessedTargetEntryPoint( - targetPath: AbsoluteFsPath, propertiesToConsider: string[], compileAllFormats: boolean) { - const packageJsonPath = AbsoluteFsPath.from(resolve(targetPath, 'package.json')); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + fs: FileSystem, targetPath: AbsoluteFsPath, propertiesToConsider: string[], + compileAllFormats: boolean) { + const packageJsonPath = AbsoluteFsPath.resolve(targetPath, 'package.json'); + const packageJson = JSON.parse(fs.readFile(packageJsonPath)); for (const property of propertiesToConsider) { if (packageJson[property]) { @@ -200,3 +195,19 @@ function hasProcessedTargetEntryPoint( // property before the first processed format that was unprocessed. return true; } + +/** + * If we get here, then the requested entry-point did not contain anything compiled by + * the old Angular compiler. Therefore there is nothing for ngcc to do. + * So mark all formats in this entry-point as processed so that clients of ngcc can avoid + * triggering ngcc for this entry-point in the future. + */ +function markNonAngularPackageAsProcessed( + fs: FileSystem, path: AbsoluteFsPath, propertiesToConsider: string[]) { + const packageJsonPath = AbsoluteFsPath.resolve(path, 'package.json'); + const packageJson = JSON.parse(fs.readFile(packageJsonPath)); + propertiesToConsider.forEach(formatProperty => { + if (packageJson[formatProperty]) + markAsProcessed(fs, packageJson, packageJsonPath, formatProperty as EntryPointJsonProperty); + }); +} diff --git a/packages/compiler-cli/ngcc/src/packages/build_marker.ts b/packages/compiler-cli/ngcc/src/packages/build_marker.ts index 5aea60f51c6f..c55dbd39c137 100644 --- a/packages/compiler-cli/ngcc/src/packages/build_marker.ts +++ b/packages/compiler-cli/ngcc/src/packages/build_marker.ts @@ -6,10 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {writeFileSync} from 'fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; - +import {FileSystem} from '../file_system/file_system'; import {EntryPointJsonProperty, EntryPointPackageJson} from './entry_point'; export const NGCC_VERSION = '0.0.0-PLACEHOLDER'; @@ -49,9 +47,9 @@ export function hasBeenProcessed( * @param format the property in the package.json of the format for which we are writing the marker. */ export function markAsProcessed( - packageJson: EntryPointPackageJson, packageJsonPath: AbsoluteFsPath, + fs: FileSystem, packageJson: EntryPointPackageJson, packageJsonPath: AbsoluteFsPath, format: EntryPointJsonProperty) { if (!packageJson.__processed_by_ivy_ngcc__) packageJson.__processed_by_ivy_ngcc__ = {}; packageJson.__processed_by_ivy_ngcc__[format] = NGCC_VERSION; - writeFileSync(packageJsonPath, JSON.stringify(packageJson), 'utf8'); + fs.writeFile(packageJsonPath, JSON.stringify(packageJson)); } diff --git a/packages/compiler-cli/ngcc/src/packages/bundle_program.ts b/packages/compiler-cli/ngcc/src/packages/bundle_program.ts index 5c8ba5eeb3bc..97ba48e2655d 100644 --- a/packages/compiler-cli/ngcc/src/packages/bundle_program.ts +++ b/packages/compiler-cli/ngcc/src/packages/bundle_program.ts @@ -5,10 +5,11 @@ * 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 {dirname, resolve} from 'canonical-path'; -import {existsSync, lstatSync, readdirSync} from 'fs'; import * as ts from 'typescript'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; + /** * An entry point bundle contains one or two programs, e.g. `src` and `dts`, * that are compiled via TypeScript. @@ -21,9 +22,9 @@ export interface BundleProgram { program: ts.Program; options: ts.CompilerOptions; host: ts.CompilerHost; - path: string; + path: AbsoluteFsPath; file: ts.SourceFile; - r3SymbolsPath: string|null; + r3SymbolsPath: AbsoluteFsPath|null; r3SymbolsFile: ts.SourceFile|null; } @@ -31,9 +32,10 @@ export interface BundleProgram { * Create a bundle program. */ export function makeBundleProgram( - isCore: boolean, path: string, r3FileName: string, options: ts.CompilerOptions, - host: ts.CompilerHost): BundleProgram { - const r3SymbolsPath = isCore ? findR3SymbolsPath(dirname(path), r3FileName) : null; + fs: FileSystem, isCore: boolean, path: AbsoluteFsPath, r3FileName: string, + options: ts.CompilerOptions, host: ts.CompilerHost): BundleProgram { + const r3SymbolsPath = + isCore ? findR3SymbolsPath(fs, AbsoluteFsPath.dirname(path), r3FileName) : null; const rootPaths = r3SymbolsPath ? [path, r3SymbolsPath] : [path]; const program = ts.createProgram(rootPaths, options, host); const file = program.getSourceFile(path) !; @@ -45,26 +47,28 @@ export function makeBundleProgram( /** * Search the given directory hierarchy to find the path to the `r3_symbols` file. */ -export function findR3SymbolsPath(directory: string, filename: string): string|null { - const r3SymbolsFilePath = resolve(directory, filename); - if (existsSync(r3SymbolsFilePath)) { +export function findR3SymbolsPath( + fs: FileSystem, directory: AbsoluteFsPath, filename: string): AbsoluteFsPath|null { + const r3SymbolsFilePath = AbsoluteFsPath.resolve(directory, filename); + if (fs.exists(r3SymbolsFilePath)) { return r3SymbolsFilePath; } const subDirectories = - readdirSync(directory) + fs.readdir(directory) // Not interested in hidden files .filter(p => !p.startsWith('.')) // Ignore node_modules .filter(p => p !== 'node_modules') // Only interested in directories (and only those that are not symlinks) .filter(p => { - const stat = lstatSync(resolve(directory, p)); + const stat = fs.lstat(AbsoluteFsPath.resolve(directory, p)); return stat.isDirectory() && !stat.isSymbolicLink(); }); for (const subDirectory of subDirectories) { - const r3SymbolsFilePath = findR3SymbolsPath(resolve(directory, subDirectory, ), filename); + const r3SymbolsFilePath = + findR3SymbolsPath(fs, AbsoluteFsPath.resolve(directory, subDirectory), filename); if (r3SymbolsFilePath) { return r3SymbolsFilePath; } diff --git a/packages/compiler-cli/ngcc/src/packages/dependency_host.ts b/packages/compiler-cli/ngcc/src/packages/dependency_host.ts deleted file mode 100644 index bd959ed5689f..000000000000 --- a/packages/compiler-cli/ngcc/src/packages/dependency_host.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @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 path from 'canonical-path'; -import * as fs from 'fs'; -import * as ts from 'typescript'; - -import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; - -/** - * Helper functions for computing dependencies. - */ -export class DependencyHost { - /** - * 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. - * @param dependencies A set that will have the absolute paths of resolved entry points added to - * it. - * @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 - * 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 - } { - const fromContents = fs.readFileSync(from, 'utf8'); - if (!this.hasImportOrReexportStatements(fromContents)) { - return {dependencies, missing, deepImports}; - } - - // Parse the source into a TypeScript AST and then walk it looking for imports and re-exports. - const sf = - ts.createSourceFile(from, fromContents, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS); - sf.statements - // filter out statements that are not imports or reexports - .filter(this.isStringImportOrReexport) - // 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); - } 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); - } 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. - * @returns true if the statement is an import with a string literal module specifier. - */ - isStringImportOrReexport(stmt: ts.Statement): stmt is ts.ImportDeclaration& - {moduleSpecifier: ts.StringLiteral} { - return ts.isImportDeclaration(stmt) || - ts.isExportDeclaration(stmt) && !!stmt.moduleSpecifier && - ts.isStringLiteral(stmt.moduleSpecifier); - } - - /** - * Check whether a source file needs to be parsed for imports. - * This is a performance short-circuit, which saves us from creating - * a TypeScript AST unnecessarily. - * - * @param source The content of the source file to check. - * - * @returns false if there are definitely no import or re-export statements - * in this file, true otherwise. - */ - hasImportOrReexportStatements(source: string): boolean { - return /(import|export)\s.+from/.test(source); - } -} diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point.ts b/packages/compiler-cli/ngcc/src/packages/entry_point.ts index a51ce1f25f9d..8a8c8bd9bc49 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point.ts @@ -5,14 +5,10 @@ * 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 fs from 'fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; import {Logger} from '../logging/logger'; - /** * The possible values for the format of an entry-point. */ @@ -33,6 +29,8 @@ export interface EntryPoint { path: AbsoluteFsPath; /** The path to a typings (.d.ts) file for this entry-point. */ typings: AbsoluteFsPath; + /** Is this EntryPoint compiled with the Angular View Engine compiler? */ + compiledByAngular: boolean; } interface PackageJsonFormatProperties { @@ -68,13 +66,14 @@ export const SUPPORTED_FORMAT_PROPERTIES: EntryPointJsonProperty[] = * @returns An entry-point if it is valid, `null` otherwise. */ export function getEntryPointInfo( - logger: Logger, packagePath: AbsoluteFsPath, entryPointPath: AbsoluteFsPath): EntryPoint|null { - const packageJsonPath = path.resolve(entryPointPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { + fs: FileSystem, logger: Logger, packagePath: AbsoluteFsPath, + entryPointPath: AbsoluteFsPath): EntryPoint|null { + const packageJsonPath = AbsoluteFsPath.resolve(entryPointPath, 'package.json'); + if (!fs.exists(packageJsonPath)) { return null; } - const entryPointPackageJson = loadEntryPointPackage(logger, packageJsonPath); + const entryPointPackageJson = loadEntryPointPackage(fs, logger, packageJsonPath); if (!entryPointPackageJson) { return null; } @@ -88,17 +87,15 @@ export function getEntryPointInfo( // Also there must exist a `metadata.json` file next to the typings entry-point. const metadataPath = - path.resolve(entryPointPath, typings.replace(/\.d\.ts$/, '') + '.metadata.json'); - if (!fs.existsSync(metadataPath)) { - return null; - } + AbsoluteFsPath.resolve(entryPointPath, typings.replace(/\.d\.ts$/, '') + '.metadata.json'); const entryPointInfo: EntryPoint = { name: entryPointPackageJson.name, packageJson: entryPointPackageJson, package: packagePath, path: entryPointPath, - typings: AbsoluteFsPath.from(path.resolve(entryPointPath, typings)), + typings: AbsoluteFsPath.resolve(entryPointPath, typings), + compiledByAngular: fs.exists(metadataPath), }; return entryPointInfo; @@ -136,10 +133,10 @@ export function getEntryPointFormat(property: string): EntryPointFormat|undefine * @param packageJsonPath the absolute path to the package.json file. * @returns JSON from the package.json file if it is valid, `null` otherwise. */ -function loadEntryPointPackage(logger: Logger, packageJsonPath: string): EntryPointPackageJson| - null { +function loadEntryPointPackage( + fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath): EntryPointPackageJson|null { try { - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return JSON.parse(fs.readFile(packageJsonPath)); } catch (e) { // We may have run into a package.json with unexpected symbols logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`); diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts b/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts index 0130d5267748..d61f12341b29 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point_bundle.ts @@ -5,14 +5,14 @@ * 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 {resolve} from 'canonical-path'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; +import {PathMappings} from '../utils'; import {BundleProgram, makeBundleProgram} from './bundle_program'; import {EntryPointFormat, EntryPointJsonProperty} from './entry_point'; - - +import {NgccCompilerHost} from './ngcc_compiler_host'; /** * A bundle of files and paths (and TS programs) that correspond to a particular @@ -38,25 +38,27 @@ export interface EntryPointBundle { * @param transformDts Whether to transform the typings along with this bundle. */ export function makeEntryPointBundle( - entryPointPath: string, formatPath: string, typingsPath: string, isCore: boolean, - formatProperty: EntryPointJsonProperty, format: EntryPointFormat, - transformDts: boolean): EntryPointBundle|null { + fs: FileSystem, entryPointPath: string, formatPath: string, typingsPath: string, + isCore: boolean, formatProperty: EntryPointJsonProperty, format: EntryPointFormat, + transformDts: boolean, pathMappings?: PathMappings): EntryPointBundle|null { // Create the TS program and necessary helpers. const options: ts.CompilerOptions = { allowJs: true, maxNodeModuleJsDepth: Infinity, - rootDir: entryPointPath, + noLib: true, + rootDir: entryPointPath, ...pathMappings }; - const host = ts.createCompilerHost(options); + const host = new NgccCompilerHost(fs, options); const rootDirs = [AbsoluteFsPath.from(entryPointPath)]; // Create the bundle programs, as necessary. const src = makeBundleProgram( - isCore, resolve(entryPointPath, formatPath), 'r3_symbols.js', options, host); - const dts = transformDts ? - makeBundleProgram( - isCore, resolve(entryPointPath, typingsPath), 'r3_symbols.d.ts', options, host) : - null; + fs, isCore, AbsoluteFsPath.resolve(entryPointPath, formatPath), 'r3_symbols.js', options, + host); + const dts = transformDts ? makeBundleProgram( + fs, isCore, AbsoluteFsPath.resolve(entryPointPath, typingsPath), + 'r3_symbols.d.ts', options, host) : + null; const isFlatCore = isCore && src.r3SymbolsFile === null; return {format, formatProperty, rootDirs, isCore, isFlatCore, src, dts}; diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts b/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts index bbb2f51778e1..f739669480a3 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts @@ -5,31 +5,65 @@ * 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 fs from 'fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {DependencyResolver, SortedEntryPointsInfo} from '../dependencies/dependency_resolver'; +import {FileSystem} from '../file_system/file_system'; import {Logger} from '../logging/logger'; - -import {DependencyResolver, SortedEntryPointsInfo} from './dependency_resolver'; +import {PathMappings} from '../utils'; import {EntryPoint, getEntryPointInfo} from './entry_point'; - export class EntryPointFinder { - constructor(private logger: Logger, private resolver: DependencyResolver) {} + constructor( + private fs: FileSystem, private logger: Logger, private resolver: DependencyResolver) {} /** * Search the given directory, and sub-directories, for Angular package entry points. * @param sourceDirectory An absolute path to the directory to search for entry points. */ - findEntryPoints(sourceDirectory: AbsoluteFsPath, targetEntryPointPath?: AbsoluteFsPath): - SortedEntryPointsInfo { - const unsortedEntryPoints = this.walkDirectoryForEntryPoints(sourceDirectory); + findEntryPoints( + sourceDirectory: AbsoluteFsPath, targetEntryPointPath?: AbsoluteFsPath, + pathMappings?: PathMappings): SortedEntryPointsInfo { + const basePaths = this.getBasePaths(sourceDirectory, pathMappings); + const unsortedEntryPoints = basePaths.reduce( + (entryPoints, basePath) => entryPoints.concat(this.walkDirectoryForEntryPoints(basePath)), + []); const targetEntryPoint = targetEntryPointPath ? unsortedEntryPoints.find(entryPoint => entryPoint.path === targetEntryPointPath) : undefined; return this.resolver.sortEntryPointsByDependency(unsortedEntryPoints, targetEntryPoint); } + /** + * Extract all the base-paths that we need to search for entry-points. + * + * This always contains the standard base-path (`sourceDirectory`). + * But it also parses the `paths` mappings object to guess additional base-paths. + * + * For example: + * + * ``` + * getBasePaths('/node_modules', {baseUrl: '/dist', paths: {'*': ['lib/*', 'lib/generated/*']}}) + * > ['/node_modules', '/dist/lib'] + * ``` + * + * Notice that `'/dist'` is not included as there is no `'*'` path, + * and `'/dist/lib/generated'` is not included as it is covered by `'/dist/lib'`. + * + * @param sourceDirectory The standard base-path (e.g. node_modules). + * @param pathMappings Path mapping configuration, from which to extract additional base-paths. + */ + private getBasePaths(sourceDirectory: AbsoluteFsPath, pathMappings?: PathMappings): + AbsoluteFsPath[] { + const basePaths = [sourceDirectory]; + if (pathMappings) { + const baseUrl = AbsoluteFsPath.resolve(pathMappings.baseUrl); + values(pathMappings.paths).forEach(paths => paths.forEach(path => { + basePaths.push(AbsoluteFsPath.join(baseUrl, extractPathPrefix(path))); + })); + } + basePaths.sort(); // Get the paths in order with the shorter ones first. + return basePaths.filter(removeDeeperPaths); + } + /** * Look for entry points that need to be compiled, starting at the source directory. * The function will recurse into directories that start with `@...`, e.g. `@angular/...`. @@ -37,29 +71,29 @@ export class EntryPointFinder { */ private walkDirectoryForEntryPoints(sourceDirectory: AbsoluteFsPath): EntryPoint[] { const entryPoints: EntryPoint[] = []; - fs.readdirSync(sourceDirectory) + this.fs + .readdir(sourceDirectory) // Not interested in hidden files .filter(p => !p.startsWith('.')) // Ignore node_modules .filter(p => p !== 'node_modules') // Only interested in directories (and only those that are not symlinks) .filter(p => { - const stat = fs.lstatSync(path.resolve(sourceDirectory, p)); + const stat = this.fs.lstat(AbsoluteFsPath.resolve(sourceDirectory, p)); return stat.isDirectory() && !stat.isSymbolicLink(); }) .forEach(p => { // Either the directory is a potential package or a namespace containing packages (e.g // `@angular`). - const packagePath = AbsoluteFsPath.from(path.join(sourceDirectory, p)); + const packagePath = AbsoluteFsPath.join(sourceDirectory, p); if (p.startsWith('@')) { entryPoints.push(...this.walkDirectoryForEntryPoints(packagePath)); } else { entryPoints.push(...this.getEntryPointsForPackage(packagePath)); // Also check for any nested node_modules in this package - const nestedNodeModulesPath = - AbsoluteFsPath.from(path.resolve(packagePath, 'node_modules')); - if (fs.existsSync(nestedNodeModulesPath)) { + const nestedNodeModulesPath = AbsoluteFsPath.resolve(packagePath, 'node_modules'); + if (this.fs.exists(nestedNodeModulesPath)) { entryPoints.push(...this.walkDirectoryForEntryPoints(nestedNodeModulesPath)); } } @@ -76,14 +110,14 @@ export class EntryPointFinder { const entryPoints: EntryPoint[] = []; // Try to get an entry point from the top level package directory - const topLevelEntryPoint = getEntryPointInfo(this.logger, packagePath, packagePath); + const topLevelEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, packagePath); if (topLevelEntryPoint !== null) { entryPoints.push(topLevelEntryPoint); } // Now search all the directories of this package for possible entry points this.walkDirectory(packagePath, subdir => { - const subEntryPoint = getEntryPointInfo(this.logger, packagePath, subdir); + const subEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, subdir); if (subEntryPoint !== null) { entryPoints.push(subEntryPoint); } @@ -99,21 +133,53 @@ export class EntryPointFinder { * @param fn the function to apply to each directory. */ private walkDirectory(dir: AbsoluteFsPath, fn: (dir: AbsoluteFsPath) => void) { - return fs - .readdirSync(dir) + return this.fs + .readdir(dir) // Not interested in hidden files .filter(p => !p.startsWith('.')) // Ignore node_modules .filter(p => p !== 'node_modules') // Only interested in directories (and only those that are not symlinks) .filter(p => { - const stat = fs.lstatSync(path.resolve(dir, p)); + const stat = this.fs.lstat(AbsoluteFsPath.resolve(dir, p)); return stat.isDirectory() && !stat.isSymbolicLink(); }) .forEach(subDir => { - const resolvedSubDir = AbsoluteFsPath.from(path.resolve(dir, subDir)); + const resolvedSubDir = AbsoluteFsPath.resolve(dir, subDir); fn(resolvedSubDir); this.walkDirectory(resolvedSubDir, fn); }); } } + +/** + * Extract everything in the `path` up to the first `*`. + * @param path The path to parse. + * @returns The extracted prefix. + */ +function extractPathPrefix(path: string) { + return path.split('*', 1)[0]; +} + +/** + * A filter function that removes paths that are already covered by higher paths. + * + * @param value The current path. + * @param index The index of the current path. + * @param array The array of paths (sorted alphabetically). + * @returns true if this path is not already covered by a previous path. + */ +function removeDeeperPaths(value: AbsoluteFsPath, index: number, array: AbsoluteFsPath[]) { + for (let i = 0; i < index; i++) { + if (value.startsWith(array[i])) return false; + } + return true; +} + +/** + * Extract all the values (not keys) from an object. + * @param obj The object to process. + */ +function values(obj: {[key: string]: T}): T[] { + return Object.keys(obj).map(key => obj[key]); +} diff --git a/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts b/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.ts new file mode 100644 index 000000000000..8f8d5cd597c4 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/packages/ngcc_compiler_host.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 os from 'os'; +import * as ts from 'typescript'; + +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; + +export class NgccCompilerHost implements ts.CompilerHost { + private _caseSensitive = this.fs.exists(AbsoluteFsPath.fromUnchecked(__filename.toUpperCase())); + + constructor(private fs: FileSystem, private options: ts.CompilerOptions) {} + + getSourceFile(fileName: string, languageVersion: ts.ScriptTarget): ts.SourceFile|undefined { + const text = this.readFile(fileName); + return text !== undefined ? ts.createSourceFile(fileName, text, languageVersion) : undefined; + } + + getDefaultLibFileName(options: ts.CompilerOptions): string { + return this.getDefaultLibLocation() + '/' + ts.getDefaultLibFileName(options); + } + + getDefaultLibLocation(): string { + const nodeLibPath = AbsoluteFsPath.fromUnchecked(require.resolve('typescript')); + return AbsoluteFsPath.join(nodeLibPath, '..'); + } + + writeFile(fileName: string, data: string): void { + this.fs.writeFile(AbsoluteFsPath.fromUnchecked(fileName), data); + } + + getCurrentDirectory(): string { return this.fs.pwd(); } + + getCanonicalFileName(fileName: string): string { + return this.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(); + } + + useCaseSensitiveFileNames(): boolean { return this._caseSensitive; } + + getNewLine(): string { + switch (this.options.newLine) { + case ts.NewLineKind.CarriageReturnLineFeed: + return '\r\n'; + case ts.NewLineKind.LineFeed: + return '\n'; + default: + return os.EOL; + } + } + + fileExists(fileName: string): boolean { + return this.fs.exists(AbsoluteFsPath.fromUnchecked(fileName)); + } + + readFile(fileName: string): string|undefined { + if (!this.fileExists(fileName)) { + return undefined; + } + return this.fs.readFile(AbsoluteFsPath.fromUnchecked(fileName)); + } +} diff --git a/packages/compiler-cli/ngcc/src/packages/transformer.ts b/packages/compiler-cli/ngcc/src/packages/transformer.ts index f4a9e7675323..3abc7078a3a0 100644 --- a/packages/compiler-cli/ngcc/src/packages/transformer.ts +++ b/packages/compiler-cli/ngcc/src/packages/transformer.ts @@ -12,6 +12,7 @@ import {ModuleWithProvidersAnalyses, ModuleWithProvidersAnalyzer} from '../analy import {NgccReferencesRegistry} from '../analysis/ngcc_references_registry'; import {ExportInfo, PrivateDeclarationsAnalyzer} from '../analysis/private_declarations_analyzer'; import {SwitchMarkerAnalyses, SwitchMarkerAnalyzer} from '../analysis/switch_marker_analyzer'; +import {FileSystem} from '../file_system/file_system'; import {Esm2015ReflectionHost} from '../host/esm2015_host'; import {Esm5ReflectionHost} from '../host/esm5_host'; import {NgccReflectionHost} from '../host/ngcc_host'; @@ -46,7 +47,7 @@ import {EntryPointBundle} from './entry_point_bundle'; * - Some formats may contain multiple "modules" in a single file. */ export class Transformer { - constructor(private logger: Logger, private sourcePath: string) {} + constructor(private fs: FileSystem, private logger: Logger) {} /** * Transform the source (and typings) files of a bundle. @@ -85,9 +86,9 @@ export class Transformer { getRenderer(host: NgccReflectionHost, isCore: boolean, bundle: EntryPointBundle): Renderer { switch (bundle.format) { case 'esm2015': - return new EsmRenderer(this.logger, host, isCore, bundle, this.sourcePath); + return new EsmRenderer(this.fs, this.logger, host, isCore, bundle); case 'esm5': - return new Esm5Renderer(this.logger, host, isCore, bundle, this.sourcePath); + return new Esm5Renderer(this.fs, this.logger, host, isCore, bundle); default: throw new Error(`Renderer for "${bundle.format}" not yet implemented.`); } @@ -102,8 +103,8 @@ export class Transformer { const switchMarkerAnalyses = switchMarkerAnalyzer.analyzeProgram(bundle.src.program); const decorationAnalyzer = new DecorationAnalyzer( - bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, reflectionHost, - referencesRegistry, bundle.rootDirs, isCore); + this.fs, bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, + reflectionHost, referencesRegistry, bundle.rootDirs, isCore); const decorationAnalyses = decorationAnalyzer.analyzeProgram(); const moduleWithProvidersAnalyzer = diff --git a/packages/compiler-cli/ngcc/src/rendering/esm5_renderer.ts b/packages/compiler-cli/ngcc/src/rendering/esm5_renderer.ts index 9268fef45b44..368c93b44f91 100644 --- a/packages/compiler-cli/ngcc/src/rendering/esm5_renderer.ts +++ b/packages/compiler-cli/ngcc/src/rendering/esm5_renderer.ts @@ -5,20 +5,21 @@ * 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 MagicString from 'magic-string'; +import * as ts from 'typescript'; +import {CompiledClass} from '../analysis/decoration_analyzer'; +import {FileSystem} from '../file_system/file_system'; import {getIifeBody} from '../host/esm5_host'; import {NgccReflectionHost} from '../host/ngcc_host'; -import {CompiledClass} from '../analysis/decoration_analyzer'; -import {EsmRenderer} from './esm_renderer'; -import {EntryPointBundle} from '../packages/entry_point_bundle'; import {Logger} from '../logging/logger'; +import {EntryPointBundle} from '../packages/entry_point_bundle'; +import {EsmRenderer} from './esm_renderer'; export class Esm5Renderer extends EsmRenderer { constructor( - logger: Logger, host: NgccReflectionHost, isCore: boolean, bundle: EntryPointBundle, - sourcePath: string) { - super(logger, host, isCore, bundle, sourcePath); + fs: FileSystem, logger: Logger, host: NgccReflectionHost, isCore: boolean, + bundle: EntryPointBundle) { + super(fs, logger, host, isCore, bundle); } /** diff --git a/packages/compiler-cli/ngcc/src/rendering/esm_renderer.ts b/packages/compiler-cli/ngcc/src/rendering/esm_renderer.ts index 8e6b4f2f1051..15fe6f61fbee 100644 --- a/packages/compiler-cli/ngcc/src/rendering/esm_renderer.ts +++ b/packages/compiler-cli/ngcc/src/rendering/esm_renderer.ts @@ -5,22 +5,23 @@ * 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 {dirname, relative} from 'canonical-path'; import MagicString from 'magic-string'; import * as ts from 'typescript'; -import {NgccReflectionHost, POST_R3_MARKER, PRE_R3_MARKER, SwitchableVariableDeclaration} from '../host/ngcc_host'; +import {PathSegment, AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {isDtsPath} from '../../../src/ngtsc/util/src/typescript'; import {CompiledClass} from '../analysis/decoration_analyzer'; -import {RedundantDecoratorMap, Renderer, stripExtension} from './renderer'; -import {EntryPointBundle} from '../packages/entry_point_bundle'; import {ExportInfo} from '../analysis/private_declarations_analyzer'; -import {isDtsPath} from '../../../src/ngtsc/util/src/typescript'; +import {FileSystem} from '../file_system/file_system'; +import {NgccReflectionHost, POST_R3_MARKER, PRE_R3_MARKER, SwitchableVariableDeclaration} from '../host/ngcc_host'; import {Logger} from '../logging/logger'; +import {EntryPointBundle} from '../packages/entry_point_bundle'; +import {RedundantDecoratorMap, Renderer, stripExtension} from './renderer'; export class EsmRenderer extends Renderer { constructor( - logger: Logger, host: NgccReflectionHost, isCore: boolean, bundle: EntryPointBundle, - sourcePath: string) { - super(logger, host, isCore, bundle, sourcePath); + fs: FileSystem, logger: Logger, host: NgccReflectionHost, isCore: boolean, + bundle: EntryPointBundle) { + super(fs, logger, host, isCore, bundle); } /** @@ -35,7 +36,7 @@ export class EsmRenderer extends Renderer { output.appendLeft(insertionPoint, renderedImports); } - addExports(output: MagicString, entryPointBasePath: string, exports: ExportInfo[]): void { + addExports(output: MagicString, entryPointBasePath: AbsoluteFsPath, exports: ExportInfo[]): void { exports.forEach(e => { let exportFrom = ''; const isDtsFile = isDtsPath(entryPointBasePath); @@ -43,7 +44,8 @@ export class EsmRenderer extends Renderer { if (from) { const basePath = stripExtension(from); - const relativePath = './' + relative(dirname(entryPointBasePath), basePath); + const relativePath = + './' + PathSegment.relative(AbsoluteFsPath.dirname(entryPointBasePath), basePath); exportFrom = entryPointBasePath !== basePath ? ` from '${relativePath}'` : ''; } diff --git a/packages/compiler-cli/ngcc/src/rendering/renderer.ts b/packages/compiler-cli/ngcc/src/rendering/renderer.ts index 39d0a9caa115..911a8aac7596 100644 --- a/packages/compiler-cli/ngcc/src/rendering/renderer.ts +++ b/packages/compiler-cli/ngcc/src/rendering/renderer.ts @@ -6,25 +6,26 @@ * found in the LICENSE file at https://angular.io/license */ import {ConstantPool, Expression, Statement, WrappedNodeExpr, WritePropExpr} from '@angular/compiler'; -import {SourceMapConverter, commentRegex, fromJSON, fromMapFileSource, fromObject, fromSource, generateMapFileComment, mapFileCommentRegex, removeComments, removeMapFileComments} from 'convert-source-map'; -import {readFileSync, statSync} from 'fs'; +import {SourceMapConverter, commentRegex, fromJSON, fromObject, fromSource, generateMapFileComment, mapFileCommentRegex, removeComments, removeMapFileComments} from 'convert-source-map'; import MagicString from 'magic-string'; -import {basename, dirname, relative, resolve} from 'canonical-path'; import {SourceMapConsumer, SourceMapGenerator, RawSourceMap} from 'source-map'; import * as ts from 'typescript'; -import {NoopImportRewriter, ImportRewriter, R3SymbolsImportRewriter, NOOP_DEFAULT_IMPORT_RECORDER} from '@angular/compiler-cli/src/ngtsc/imports'; -import {CompileResult} from '@angular/compiler-cli/src/ngtsc/transform'; +import {NoopImportRewriter, ImportRewriter, R3SymbolsImportRewriter, NOOP_DEFAULT_IMPORT_RECORDER} from '../../../src/ngtsc/imports'; +import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; +import {CompileResult} from '../../../src/ngtsc/transform'; import {translateStatement, translateType, ImportManager} from '../../../src/ngtsc/translator'; -import {NgccFlatImportRewriter} from './ngcc_import_rewriter'; + import {CompiledClass, CompiledFile, DecorationAnalyses} from '../analysis/decoration_analyzer'; import {ModuleWithProvidersInfo, ModuleWithProvidersAnalyses} from '../analysis/module_with_providers_analyzer'; import {PrivateDeclarationsAnalyses, ExportInfo} from '../analysis/private_declarations_analyzer'; import {SwitchMarkerAnalyses, SwitchMarkerAnalysis} from '../analysis/switch_marker_analyzer'; import {IMPORT_PREFIX} from '../constants'; +import {FileSystem} from '../file_system/file_system'; import {NgccReflectionHost, SwitchableVariableDeclaration} from '../host/ngcc_host'; -import {EntryPointBundle} from '../packages/entry_point_bundle'; import {Logger} from '../logging/logger'; +import {EntryPointBundle} from '../packages/entry_point_bundle'; +import {NgccFlatImportRewriter} from './ngcc_import_rewriter'; interface SourceMapInfo { source: string; @@ -39,7 +40,7 @@ export interface FileInfo { /** * Path to where the file should be written. */ - path: string; + path: AbsoluteFsPath; /** * The contents of the file to be be written. */ @@ -81,8 +82,8 @@ export const RedundantDecoratorMap = Map; */ export abstract class Renderer { constructor( - protected logger: Logger, protected host: NgccReflectionHost, protected isCore: boolean, - protected bundle: EntryPointBundle, protected sourcePath: string) {} + protected fs: FileSystem, protected logger: Logger, protected host: NgccReflectionHost, + protected isCore: boolean, protected bundle: EntryPointBundle) {} renderProgram( decorationAnalyses: DecorationAnalyses, switchMarkerAnalyses: SwitchMarkerAnalyses, @@ -189,7 +190,7 @@ export abstract class Renderer { this.addModuleWithProvidersParams(outputText, renderInfo.moduleWithProviders, importManager); this.addImports(outputText, importManager.getAllImports(dtsFile.fileName), dtsFile); - this.addExports(outputText, dtsFile.fileName, renderInfo.privateExports); + this.addExports(outputText, AbsoluteFsPath.fromSourceFile(dtsFile), renderInfo.privateExports); return this.renderSourceAndMap(dtsFile, input, outputText); @@ -207,11 +208,12 @@ export abstract class Renderer { importManager: ImportManager): void { moduleWithProviders.forEach(info => { const ngModuleName = info.ngModule.node.name.text; - const declarationFile = info.declaration.getSourceFile().fileName; - const ngModuleFile = info.ngModule.node.getSourceFile().fileName; + const declarationFile = AbsoluteFsPath.fromSourceFile(info.declaration.getSourceFile()); + const ngModuleFile = AbsoluteFsPath.fromSourceFile(info.ngModule.node.getSourceFile()); const importPath = info.ngModule.viaModule || (declarationFile !== ngModuleFile ? - stripExtension(`./${relative(dirname(declarationFile), ngModuleFile)}`) : + stripExtension( + `./${PathSegment.relative(AbsoluteFsPath.dirname(declarationFile), ngModuleFile)}`) : null); const ngModule = getImportString(importManager, importPath, ngModuleName); @@ -252,7 +254,7 @@ export abstract class Renderer { output: MagicString, imports: {specifier: string, qualifier: string}[], sf: ts.SourceFile): void; protected abstract addExports( - output: MagicString, entryPointBasePath: string, exports: ExportInfo[]): void; + output: MagicString, entryPointBasePath: AbsoluteFsPath, exports: ExportInfo[]): void; protected abstract addDefinitions( output: MagicString, compiledClass: CompiledClass, definitions: string): void; protected abstract removeDecorators( @@ -288,7 +290,7 @@ export abstract class Renderer { */ protected extractSourceMap(file: ts.SourceFile): SourceMapInfo { const inline = commentRegex.test(file.text); - const external = mapFileCommentRegex.test(file.text); + const external = mapFileCommentRegex.exec(file.text); if (inline) { const inlineSourceMap = fromSource(file.text); @@ -300,17 +302,22 @@ export abstract class Renderer { } else if (external) { let externalSourceMap: SourceMapConverter|null = null; try { - externalSourceMap = fromMapFileSource(file.text, dirname(file.fileName)); + const fileName = external[1] || external[2]; + const filePath = AbsoluteFsPath.resolve( + AbsoluteFsPath.dirname(AbsoluteFsPath.fromSourceFile(file)), fileName); + const mappingFile = this.fs.readFile(filePath); + externalSourceMap = fromJSON(mappingFile); } catch (e) { if (e.code === 'ENOENT') { this.logger.warn( `The external map file specified in the source code comment "${e.path}" was not found on the file system.`); - const mapPath = file.fileName + '.map'; - if (basename(e.path) !== basename(mapPath) && statSync(mapPath).isFile()) { + const mapPath = AbsoluteFsPath.fromUnchecked(file.fileName + '.map'); + if (PathSegment.basename(e.path) !== PathSegment.basename(mapPath) && + this.fs.stat(mapPath).isFile()) { this.logger.warn( - `Guessing the map file name from the source file name: "${basename(mapPath)}"`); + `Guessing the map file name from the source file name: "${PathSegment.basename(mapPath)}"`); try { - externalSourceMap = fromObject(JSON.parse(readFileSync(mapPath, 'utf8'))); + externalSourceMap = fromObject(JSON.parse(this.fs.readFile(mapPath))); } catch (e) { this.logger.error(e); } @@ -333,9 +340,9 @@ export abstract class Renderer { */ protected renderSourceAndMap( sourceFile: ts.SourceFile, input: SourceMapInfo, output: MagicString): FileInfo[] { - const outputPath = sourceFile.fileName; - const outputMapPath = `${outputPath}.map`; - const relativeSourcePath = basename(outputPath); + const outputPath = AbsoluteFsPath.fromSourceFile(sourceFile); + const outputMapPath = AbsoluteFsPath.fromUnchecked(`${outputPath}.map`); + const relativeSourcePath = PathSegment.basename(outputPath); const relativeMapPath = `${relativeSourcePath}.map`; const outputMap = output.generateMap({ @@ -502,8 +509,8 @@ export function renderDefinitions( return definitions; } -export function stripExtension(filePath: string): string { - return filePath.replace(/\.(js|d\.ts)$/, ''); +export function stripExtension(filePath: T): T { + return filePath.replace(/\.(js|d\.ts)$/, '') as T; } /** diff --git a/packages/compiler-cli/ngcc/src/utils.ts b/packages/compiler-cli/ngcc/src/utils.ts index cce404ad3d5b..9cb91a2c097e 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/src/writing/in_place_file_writer.ts b/packages/compiler-cli/ngcc/src/writing/in_place_file_writer.ts index 3a5790c85b3b..6bf43e94caa9 100644 --- a/packages/compiler-cli/ngcc/src/writing/in_place_file_writer.ts +++ b/packages/compiler-cli/ngcc/src/writing/in_place_file_writer.ts @@ -6,15 +6,11 @@ * 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 {dirname} from 'canonical-path'; -import {existsSync, writeFileSync} from 'fs'; -import {mkdir, mv} from 'shelljs'; - +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../file_system/file_system'; import {EntryPoint} from '../packages/entry_point'; import {EntryPointBundle} from '../packages/entry_point_bundle'; import {FileInfo} from '../rendering/renderer'; - import {FileWriter} from './file_writer'; /** @@ -22,19 +18,22 @@ import {FileWriter} from './file_writer'; * a back-up of the original file with an extra `.bak` extension. */ export class InPlaceFileWriter implements FileWriter { + constructor(protected fs: FileSystem) {} + writeBundle(_entryPoint: EntryPoint, _bundle: EntryPointBundle, transformedFiles: FileInfo[]) { transformedFiles.forEach(file => this.writeFileAndBackup(file)); } + protected writeFileAndBackup(file: FileInfo): void { - mkdir('-p', dirname(file.path)); - const backPath = file.path + '.__ivy_ngcc_bak'; - if (existsSync(backPath)) { + this.fs.ensureDir(AbsoluteFsPath.dirname(file.path)); + const backPath = AbsoluteFsPath.fromUnchecked(`${file.path}.__ivy_ngcc_bak`); + if (this.fs.exists(backPath)) { throw new Error( `Tried to overwrite ${backPath} with an ngcc back up file, which is disallowed.`); } - if (existsSync(file.path)) { - mv(file.path, backPath); + if (this.fs.exists(file.path)) { + this.fs.moveFile(file.path, backPath); } - writeFileSync(file.path, file.contents, 'utf8'); + this.fs.writeFile(file.path, file.contents); } } diff --git a/packages/compiler-cli/ngcc/src/writing/new_entry_point_file_writer.ts b/packages/compiler-cli/ngcc/src/writing/new_entry_point_file_writer.ts index f95160045f8c..10414ebdfc0c 100644 --- a/packages/compiler-cli/ngcc/src/writing/new_entry_point_file_writer.ts +++ b/packages/compiler-cli/ngcc/src/writing/new_entry_point_file_writer.ts @@ -6,12 +6,7 @@ * 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 {dirname, join, relative} from 'canonical-path'; -import {writeFileSync} from 'fs'; -import {cp, mkdir} from 'shelljs'; - -import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; import {isDtsPath} from '../../../src/ngtsc/util/src/typescript'; import {EntryPoint, EntryPointJsonProperty} from '../packages/entry_point'; import {EntryPointBundle} from '../packages/entry_point_bundle'; @@ -32,7 +27,7 @@ const NGCC_DIRECTORY = '__ivy_ngcc__'; export class NewEntryPointFileWriter extends InPlaceFileWriter { writeBundle(entryPoint: EntryPoint, bundle: EntryPointBundle, transformedFiles: FileInfo[]) { // The new folder is at the root of the overall package - const ngccFolder = AbsoluteFsPath.fromUnchecked(join(entryPoint.package, NGCC_DIRECTORY)); + const ngccFolder = AbsoluteFsPath.join(entryPoint.package, NGCC_DIRECTORY); this.copyBundle(bundle, entryPoint.package, ngccFolder); transformedFiles.forEach(file => this.writeFile(file, entryPoint.package, ngccFolder)); this.updatePackageJson(entryPoint, bundle.formatProperty, ngccFolder); @@ -41,12 +36,13 @@ export class NewEntryPointFileWriter extends InPlaceFileWriter { protected copyBundle( bundle: EntryPointBundle, packagePath: AbsoluteFsPath, ngccFolder: AbsoluteFsPath) { bundle.src.program.getSourceFiles().forEach(sourceFile => { - const relativePath = relative(packagePath, sourceFile.fileName); + const relativePath = + PathSegment.relative(packagePath, AbsoluteFsPath.fromSourceFile(sourceFile)); const isOutsidePackage = relativePath.startsWith('..'); if (!sourceFile.isDeclarationFile && !isOutsidePackage) { - const newFilePath = join(ngccFolder, relativePath); - mkdir('-p', dirname(newFilePath)); - cp(sourceFile.fileName, newFilePath); + const newFilePath = AbsoluteFsPath.join(ngccFolder, relativePath); + this.fs.ensureDir(AbsoluteFsPath.dirname(newFilePath)); + this.fs.copyFile(AbsoluteFsPath.fromSourceFile(sourceFile), newFilePath); } }); } @@ -57,19 +53,24 @@ export class NewEntryPointFileWriter extends InPlaceFileWriter { // This is either `.d.ts` or `.d.ts.map` file super.writeFileAndBackup(file); } else { - const relativePath = relative(packagePath, file.path); - const newFilePath = join(ngccFolder, relativePath); - mkdir('-p', dirname(newFilePath)); - writeFileSync(newFilePath, file.contents, 'utf8'); + const relativePath = PathSegment.relative(packagePath, file.path); + const newFilePath = AbsoluteFsPath.join(ngccFolder, relativePath); + this.fs.ensureDir(AbsoluteFsPath.dirname(newFilePath)); + this.fs.writeFile(newFilePath, file.contents); } } protected updatePackageJson( entryPoint: EntryPoint, formatProperty: EntryPointJsonProperty, ngccFolder: AbsoluteFsPath) { - const formatPath = join(entryPoint.path, entryPoint.packageJson[formatProperty] !); - const newFormatPath = join(ngccFolder, relative(entryPoint.package, formatPath)); + const formatPath = + AbsoluteFsPath.join(entryPoint.path, entryPoint.packageJson[formatProperty] !); + const newFormatPath = + AbsoluteFsPath.join(ngccFolder, PathSegment.relative(entryPoint.package, formatPath)); const newFormatProperty = formatProperty + '_ivy_ngcc'; - (entryPoint.packageJson as any)[newFormatProperty] = relative(entryPoint.path, newFormatPath); - writeFileSync(join(entryPoint.path, 'package.json'), JSON.stringify(entryPoint.packageJson)); + (entryPoint.packageJson as any)[newFormatProperty] = + PathSegment.relative(entryPoint.path, newFormatPath); + this.fs.writeFile( + AbsoluteFsPath.join(entryPoint.path, 'package.json'), + JSON.stringify(entryPoint.packageJson)); } } diff --git a/packages/compiler-cli/ngcc/test/analysis/decoration_analyzer_spec.ts b/packages/compiler-cli/ngcc/test/analysis/decoration_analyzer_spec.ts index c3f629bacda6..7dddb28c6bc4 100644 --- a/packages/compiler-cli/ngcc/test/analysis/decoration_analyzer_spec.ts +++ b/packages/compiler-cli/ngcc/test/analysis/decoration_analyzer_spec.ts @@ -13,12 +13,15 @@ import {DecoratorHandler, DetectResult} from '../../../src/ngtsc/transform'; import {CompiledClass, DecorationAnalyses, DecorationAnalyzer} from '../../src/analysis/decoration_analyzer'; import {NgccReferencesRegistry} from '../../src/analysis/ngcc_references_registry'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; +import {Folder, MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; -import {makeTestBundleProgram} from '../helpers/utils'; +import {createFileSystemFromProgramFiles, makeTestBundleProgram} from '../helpers/utils'; + +const _ = AbsoluteFsPath.fromUnchecked; const TEST_PROGRAM = [ { - name: 'test.js', + name: _('/test.js'), contents: ` import {Component, Directive, Injectable} from '@angular/core'; @@ -33,7 +36,7 @@ const TEST_PROGRAM = [ `, }, { - name: 'other.js', + name: _('/other.js'), contents: ` import {Component} from '@angular/core'; @@ -45,7 +48,7 @@ const TEST_PROGRAM = [ const INTERNAL_COMPONENT_PROGRAM = [ { - name: 'entrypoint.js', + name: _('/entrypoint.js'), contents: ` import {Component, NgModule} from '@angular/core'; import {ImportedComponent} from './component'; @@ -61,7 +64,7 @@ const INTERNAL_COMPONENT_PROGRAM = [ ` }, { - name: 'component.js', + name: _('/component.js'), contents: ` import {Component} from '@angular/core'; export class ImportedComponent {} @@ -136,8 +139,9 @@ describe('DecorationAnalyzer', () => { const reflectionHost = new Esm2015ReflectionHost(new MockLogger(), false, program.getTypeChecker()); const referencesRegistry = new NgccReferencesRegistry(reflectionHost); + const fs = new MockFileSystem(createFileSystemFromProgramFiles(...progArgs)); const analyzer = new DecorationAnalyzer( - program, options, host, program.getTypeChecker(), reflectionHost, referencesRegistry, + fs, program, options, host, program.getTypeChecker(), reflectionHost, referencesRegistry, [AbsoluteFsPath.fromUnchecked('/')], false); testHandler = createTestHandler(); analyzer.handlers = [testHandler]; diff --git a/packages/compiler-cli/ngcc/test/analysis/private_declarations_analyzer_spec.ts b/packages/compiler-cli/ngcc/test/analysis/private_declarations_analyzer_spec.ts index 85a7edda8ec6..e4bd4acb3b6a 100644 --- a/packages/compiler-cli/ngcc/test/analysis/private_declarations_analyzer_spec.ts +++ b/packages/compiler-cli/ngcc/test/analysis/private_declarations_analyzer_spec.ts @@ -9,12 +9,15 @@ import * as ts from 'typescript'; import {Reference} from '../../../src/ngtsc/imports'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {NgccReferencesRegistry} from '../../src/analysis/ngcc_references_registry'; import {PrivateDeclarationsAnalyzer} from '../../src/analysis/private_declarations_analyzer'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {MockLogger} from '../helpers/mock_logger'; import {getDeclaration, makeTestBundleProgram, makeTestProgram} from '../helpers/utils'; +const _ = AbsoluteFsPath.fromUnchecked; + describe('PrivateDeclarationsAnalyzer', () => { describe('analyzeProgram()', () => { @@ -147,11 +150,11 @@ describe('PrivateDeclarationsAnalyzer', () => { // not added to the ReferencesRegistry (i.e. they were not declared in an NgModule). expect(analyses.length).toEqual(2); expect(analyses).toEqual([ - {identifier: 'PrivateComponent1', from: '/src/b.js', dtsFrom: null, alias: null}, + {identifier: 'PrivateComponent1', from: _('/src/b.js'), dtsFrom: null, alias: null}, { identifier: 'InternalComponent1', - from: '/src/c.js', - dtsFrom: '/typings/c.d.ts', + from: _('/src/c.js'), + dtsFrom: _('/typings/c.d.ts'), alias: null }, ]); @@ -207,7 +210,7 @@ describe('PrivateDeclarationsAnalyzer', () => { const analyses = analyzer.analyzeProgram(program); expect(analyses).toEqual([{ identifier: 'ComponentOne', - from: '/src/a.js', + from: _('/src/a.js'), dtsFrom: null, alias: 'aliasedComponentOne', }]); diff --git a/packages/compiler-cli/ngcc/test/analysis/switch_marker_analyzer_spec.ts b/packages/compiler-cli/ngcc/test/analysis/switch_marker_analyzer_spec.ts index 5edbfb827b7f..f97ed131e260 100644 --- a/packages/compiler-cli/ngcc/test/analysis/switch_marker_analyzer_spec.ts +++ b/packages/compiler-cli/ngcc/test/analysis/switch_marker_analyzer_spec.ts @@ -5,8 +5,6 @@ * 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 {SwitchMarkerAnalyzer} from '../../src/analysis/switch_marker_analyzer'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {MockLogger} from '../helpers/mock_logger'; diff --git a/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts b/packages/compiler-cli/ngcc/test/dependencies/dependency_resolver_spec.ts similarity index 64% rename from packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts rename to packages/compiler-cli/ngcc/test/dependencies/dependency_resolver_spec.ts index 60c8f16d303d..9b5a5c801620 100644 --- a/packages/compiler-cli/ngcc/test/packages/dependency_resolver_spec.ts +++ b/packages/compiler-cli/ngcc/test/dependencies/dependency_resolver_spec.ts @@ -6,45 +6,68 @@ * found in the LICENSE file at https://angular.io/license */ import {AbsoluteFsPath} from '../../../src/ngtsc/path'; -import {DependencyHost} from '../../src/packages/dependency_host'; -import {DependencyResolver, SortedEntryPointsInfo} from '../../src/packages/dependency_resolver'; +import {DependencyResolver, SortedEntryPointsInfo} from '../../src/dependencies/dependency_resolver'; +import {EsmDependencyHost} from '../../src/dependencies/esm_dependency_host'; +import {ModuleResolver} from '../../src/dependencies/module_resolver'; import {EntryPoint} from '../../src/packages/entry_point'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; const _ = AbsoluteFsPath.from; describe('DependencyResolver', () => { - let host: DependencyHost; + let host: EsmDependencyHost; let resolver: DependencyResolver; beforeEach(() => { - host = new DependencyHost(); + const fs = new MockFileSystem(); + host = new EsmDependencyHost(fs, new ModuleResolver(fs)); resolver = new DependencyResolver(new MockLogger(), host); }); describe('sortEntryPointsByDependency()', () => { - const first = { path: _('/first'), packageJson: {esm5: 'index.ts'} } as EntryPoint; - const second = { path: _('/second'), packageJson: {esm2015: 'sub/index.ts'} } as EntryPoint; - const third = { path: _('/third'), packageJson: {esm5: 'index.ts'} } as EntryPoint; - const fourth = { path: _('/fourth'), packageJson: {esm2015: 'sub2/index.ts'} } as EntryPoint; - const fifth = { path: _('/fifth'), packageJson: {esm5: 'index.ts'} } as EntryPoint; + const first = { + path: _('/first'), + packageJson: {esm5: './index.js'}, + compiledByAngular: true + } as EntryPoint; + const second = { + path: _('/second'), + packageJson: {esm2015: './sub/index.js'}, + compiledByAngular: true + } as EntryPoint; + const third = { + path: _('/third'), + packageJson: {fesm5: './index.js'}, + compiledByAngular: true + } as EntryPoint; + const fourth = { + path: _('/fourth'), + packageJson: {fesm2015: './sub2/index.js'}, + compiledByAngular: true + } as EntryPoint; + const fifth = { + path: _('/fifth'), + packageJson: {module: './index.js'}, + compiledByAngular: true + } as EntryPoint; const dependencies = { - [_('/first/index.ts')]: {resolved: [second.path, third.path, '/ignored-1'], missing: []}, - [_('/second/sub/index.ts')]: {resolved: [third.path, fifth.path], missing: []}, - [_('/third/index.ts')]: {resolved: [fourth.path, '/ignored-2'], missing: []}, - [_('/fourth/sub2/index.ts')]: {resolved: [fifth.path], missing: []}, - [_('/fifth/index.ts')]: {resolved: [], missing: []}, + [_('/first/index.js')]: {resolved: [second.path, third.path, '/ignored-1'], missing: []}, + [_('/second/sub/index.js')]: {resolved: [third.path, fifth.path], missing: []}, + [_('/third/index.js')]: {resolved: [fourth.path, '/ignored-2'], missing: []}, + [_('/fourth/sub2/index.js')]: {resolved: [fifth.path], missing: []}, + [_('/fifth/index.js')]: {resolved: [], missing: []}, }; it('should order the entry points by their dependency on each other', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies(dependencies)); + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies(dependencies)); const result = resolver.sortEntryPointsByDependency([fifth, first, fourth, second, third]); expect(result.entryPoints).toEqual([fifth, fourth, third, second, first]); }); it('should remove entry-points that have missing direct dependencies', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies({ - [_('/first/index.ts')]: {resolved: [], missing: ['/missing']}, - [_('/second/sub/index.ts')]: {resolved: [], missing: []}, + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies({ + [_('/first/index.js')]: {resolved: [], missing: ['/missing']}, + [_('/second/sub/index.js')]: {resolved: [], missing: []}, })); const result = resolver.sortEntryPointsByDependency([first, second]); expect(result.entryPoints).toEqual([second]); @@ -54,10 +77,10 @@ describe('DependencyResolver', () => { }); it('should remove entry points that depended upon an invalid entry-point', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies({ - [_('/first/index.ts')]: {resolved: [second.path], missing: []}, - [_('/second/sub/index.ts')]: {resolved: [], missing: ['/missing']}, - [_('/third/index.ts')]: {resolved: [], missing: []}, + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies({ + [_('/first/index.js')]: {resolved: [second.path], missing: []}, + [_('/second/sub/index.js')]: {resolved: [], missing: ['/missing']}, + [_('/third/index.js')]: {resolved: [], missing: []}, })); // Note that we will process `first` before `second`, which has the missing dependency. const result = resolver.sortEntryPointsByDependency([first, second, third]); @@ -69,10 +92,10 @@ describe('DependencyResolver', () => { }); it('should remove entry points that will depend upon an invalid entry-point', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies({ - [_('/first/index.ts')]: {resolved: [second.path], missing: []}, - [_('/second/sub/index.ts')]: {resolved: [], missing: ['/missing']}, - [_('/third/index.ts')]: {resolved: [], missing: []}, + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies({ + [_('/first/index.js')]: {resolved: [second.path], missing: []}, + [_('/second/sub/index.js')]: {resolved: [], missing: ['/missing']}, + [_('/third/index.js')]: {resolved: [], missing: []}, })); // Note that we will process `first` after `second`, which has the missing dependency. const result = resolver.sortEntryPointsByDependency([second, first, third]); @@ -85,12 +108,12 @@ describe('DependencyResolver', () => { it('should error if the entry point does not have either the esm5 nor esm2015 formats', () => { expect(() => resolver.sortEntryPointsByDependency([ - { path: '/first', packageJson: {} } as EntryPoint + { path: '/first', packageJson: {}, compiledByAngular: true } as EntryPoint ])).toThrowError(`There is no format with import statements in '/first' entry-point.`); }); it('should capture any dependencies that were ignored', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies(dependencies)); + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies(dependencies)); const result = resolver.sortEntryPointsByDependency([fifth, first, fourth, second, third]); expect(result.ignoredDependencies).toEqual([ {entryPoint: first, dependencyPath: '/ignored-1'}, @@ -99,7 +122,7 @@ describe('DependencyResolver', () => { }); it('should only return dependencies of the target, if provided', () => { - spyOn(host, 'computeDependencies').and.callFake(createFakeComputeDependencies(dependencies)); + spyOn(host, 'findDependencies').and.callFake(createFakeComputeDependencies(dependencies)); const entryPoints = [fifth, first, fourth, second, third]; let sorted: SortedEntryPointsInfo; diff --git a/packages/compiler-cli/ngcc/test/dependencies/esm_dependency_host_spec.ts b/packages/compiler-cli/ngcc/test/dependencies/esm_dependency_host_spec.ts new file mode 100644 index 000000000000..bea7dc250f08 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/dependencies/esm_dependency_host_spec.ts @@ -0,0 +1,228 @@ +/** + * @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 {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {EsmDependencyHost} from '../../src/dependencies/esm_dependency_host'; +import {ModuleResolver} from '../../src/dependencies/module_resolver'; +import {MockFileSystem} from '../helpers/mock_file_system'; + +const _ = AbsoluteFsPath.from; + +describe('DependencyHost', () => { + let host: EsmDependencyHost; + beforeEach(() => { + const fs = createMockFileSystem(); + host = new EsmDependencyHost(fs, new ModuleResolver(fs)); + }); + + describe('getDependencies()', () => { + it('should not generate a TS AST if the source does not contain any imports or re-exports', + () => { + spyOn(ts, 'createSourceFile'); + host.findDependencies(_('/no/imports/or/re-exports/index.js')); + expect(ts.createSourceFile).not.toHaveBeenCalled(); + }); + + it('should resolve all the external imports of the source file', () => { + const {dependencies, missing, deepImports} = + host.findDependencies(_('/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', () => { + const {dependencies, missing, deepImports} = + host.findDependencies(_('/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', () => { + const {dependencies, missing, deepImports} = + host.findDependencies(_('/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); + }); + + it('should not register deep imports as missing', () => { + // 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 {dependencies, missing, deepImports} = + host.findDependencies(_('/external/deep-import/index.js')); + + expect(dependencies.size).toBe(0); + expect(missing.size).toBe(0); + expect(deepImports.size).toBe(1); + expect(deepImports.has('/node_modules/lib-1/deep/import')).toBe(true); + }); + + it('should recurse into internal dependencies', () => { + const {dependencies, missing, deepImports} = + host.findDependencies(_('/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', () => { + const {dependencies, missing, deepImports} = + host.findDependencies(_('/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', () => { + const fs = createMockFileSystem(); + host = new EsmDependencyHost(fs, new ModuleResolver(fs, { + baseUrl: '/dist', + paths: { + '@app/*': ['*'], + '@lib/*/test': ['lib/*/test'], + } + })); + const {dependencies, missing, deepImports} = host.findDependencies(_('/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() { + return new MockFileSystem({ + '/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('isStringImportOrReexport', () => { + it('should return true if the statement is an import', () => { + expect(host.isStringImportOrReexport(createStatement('import {X} from "some/x";'))) + .toBe(true); + expect(host.isStringImportOrReexport(createStatement('import * as X from "some/x";'))) + .toBe(true); + }); + + it('should return true if the statement is a re-export', () => { + expect(host.isStringImportOrReexport(createStatement('export {X} from "some/x";'))) + .toBe(true); + expect(host.isStringImportOrReexport(createStatement('export * from "some/x";'))).toBe(true); + }); + + it('should return false if the statement is not an import or a re-export', () => { + expect(host.isStringImportOrReexport(createStatement('class X {}'))).toBe(false); + expect(host.isStringImportOrReexport(createStatement('export function foo() {}'))) + .toBe(false); + expect(host.isStringImportOrReexport(createStatement('export const X = 10;'))).toBe(false); + }); + + function createStatement(source: string) { + return ts + .createSourceFile('source.js', source, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS) + .statements[0]; + } + }); + + describe('hasImportOrReexportStatements', () => { + it('should return true if there is an import statement', () => { + expect(host.hasImportOrReexportStatements('import {X} from "some/x";')).toBe(true); + expect(host.hasImportOrReexportStatements('import * as X from "some/x";')).toBe(true); + expect( + host.hasImportOrReexportStatements('blah blah\n\n import {X} from "some/x";\nblah blah')) + .toBe(true); + expect(host.hasImportOrReexportStatements('\t\timport {X} from "some/x";')).toBe(true); + }); + it('should return true if there is a re-export statement', () => { + expect(host.hasImportOrReexportStatements('export {X} from "some/x";')).toBe(true); + expect( + host.hasImportOrReexportStatements('blah blah\n\n export {X} from "some/x";\nblah blah')) + .toBe(true); + expect(host.hasImportOrReexportStatements('\t\texport {X} from "some/x";')).toBe(true); + expect(host.hasImportOrReexportStatements( + 'blah blah\n\n export * from "@angular/core;\nblah blah')) + .toBe(true); + }); + it('should return false if there is no import nor re-export statement', () => { + expect(host.hasImportOrReexportStatements('blah blah')).toBe(false); + expect(host.hasImportOrReexportStatements('export function moo() {}')).toBe(false); + expect( + host.hasImportOrReexportStatements('Some text that happens to include the word import')) + .toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/compiler-cli/ngcc/test/dependencies/module_resolver_spec.ts b/packages/compiler-cli/ngcc/test/dependencies/module_resolver_spec.ts new file mode 100644 index 000000000000..245a191f0ab8 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/dependencies/module_resolver_spec.ts @@ -0,0 +1,207 @@ +/** + * @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 {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {ModuleResolver, ResolvedDeepImport, ResolvedExternalModule, ResolvedRelativeModule} from '../../src/dependencies/module_resolver'; +import {MockFileSystem} from '../helpers/mock_file_system'; + +const _ = AbsoluteFsPath.from; + +function createMockFileSystem() { + return new MockFileSystem({ + '/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', + } + } + }); +} + + +describe('ModuleResolver', () => { + describe('resolveModule()', () => { + describe('with relative paths', () => { + it('should resolve sibling, child and aunt modules', () => { + const resolver = new ModuleResolver(createMockFileSystem()); + 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(createMockFileSystem()); + 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(createMockFileSystem()); + 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(createMockFileSystem()); + 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(createMockFileSystem()); + 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(createMockFileSystem()); + 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(createMockFileSystem()); + 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( + createMockFileSystem(), {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(createMockFileSystem(), { + 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; + + const fs = createMockFileSystem(); + resolver = new ModuleResolver(fs, {baseUrl: '/dist', paths: {'*': ['*', 'sub-folder/*']}}); + expect(resolver.resolveModuleImport('package-4', _('/libs/local-package/index.js'))) + .toEqual(new ResolvedExternalModule(_('/dist/package-4'))); + + resolver = new ModuleResolver(fs, {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( + createMockFileSystem(), {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( + createMockFileSystem(), {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( + createMockFileSystem(), {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( + createMockFileSystem(), + {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/helpers/mock_file_system.ts b/packages/compiler-cli/ngcc/test/helpers/mock_file_system.ts new file mode 100644 index 000000000000..c47e2f359edb --- /dev/null +++ b/packages/compiler-cli/ngcc/test/helpers/mock_file_system.ts @@ -0,0 +1,173 @@ +/** + * @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 {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; +import {FileStats, FileSystem} from '../../src/file_system/file_system'; + +/** + * An in-memory file system that can be used in unit tests. + */ +export class MockFileSystem implements FileSystem { + files: Folder = {}; + constructor(...folders: Folder[]) { + folders.forEach(files => this.processFiles(this.files, files)); + } + + exists(path: AbsoluteFsPath): boolean { return this.findFromPath(path) !== null; } + + readFile(path: AbsoluteFsPath): string { + const file = this.findFromPath(path); + if (isFile(file)) { + return file; + } else { + throw new MockFileSystemError('ENOENT', path, `File "${path}" does not exist.`); + } + } + + writeFile(path: AbsoluteFsPath, data: string): void { + const [folderPath, basename] = this.splitIntoFolderAndFile(path); + const folder = this.findFromPath(folderPath); + if (!isFolder(folder)) { + throw new MockFileSystemError( + 'ENOENT', path, `Unable to write file "${path}". The containing folder does not exist.`); + } + folder[basename] = data; + } + + readdir(path: AbsoluteFsPath): PathSegment[] { + const folder = this.findFromPath(path); + if (folder === null) { + throw new MockFileSystemError( + 'ENOENT', path, `Unable to read directory "${path}". It does not exist.`); + } + if (isFile(folder)) { + throw new MockFileSystemError( + 'ENOTDIR', path, `Unable to read directory "${path}". It is a file.`); + } + return Object.keys(folder) as PathSegment[]; + } + + lstat(path: AbsoluteFsPath): FileStats { + const fileOrFolder = this.findFromPath(path); + if (fileOrFolder === null) { + throw new MockFileSystemError('ENOENT', path, `File "${path}" does not exist.`); + } + return new MockFileStats(fileOrFolder); + } + + stat(path: AbsoluteFsPath): FileStats { + const fileOrFolder = this.findFromPath(path, {followSymLinks: true}); + if (fileOrFolder === null) { + throw new MockFileSystemError('ENOENT', path, `File "${path}" does not exist.`); + } + return new MockFileStats(fileOrFolder); + } + + pwd(): AbsoluteFsPath { return AbsoluteFsPath.fromUnchecked('/'); } + + copyFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void { + this.writeFile(to, this.readFile(from)); + } + + moveFile(from: AbsoluteFsPath, to: AbsoluteFsPath): void { + this.writeFile(to, this.readFile(from)); + const folder = this.findFromPath(AbsoluteFsPath.dirname(from)) as Folder; + const basename = PathSegment.basename(from); + delete folder[basename]; + } + + ensureDir(path: AbsoluteFsPath): void { this.ensureFolders(this.files, path.split('/')); } + + private processFiles(current: Folder, files: Folder): void { + Object.keys(files).forEach(path => { + const segments = path.split('/'); + const lastSegment = segments.pop() !; + const containingFolder = this.ensureFolders(current, segments); + const entity = files[path]; + if (isFolder(entity)) { + const processedFolder = containingFolder[lastSegment] = {} as Folder; + this.processFiles(processedFolder, entity); + } else { + containingFolder[lastSegment] = entity; + } + }); + } + + private ensureFolders(current: Folder, segments: string[]): Folder { + for (const segment of segments) { + if (isFile(current[segment])) { + throw new Error(`Folder already exists as a file.`); + } + if (!current[segment]) { + current[segment] = {}; + } + current = current[segment] as Folder; + } + return current; + } + + private findFromPath(path: AbsoluteFsPath, options?: {followSymLinks: boolean}): Entity|null { + const followSymLinks = !!options && options.followSymLinks; + const segments = path.split('/'); + let current = this.files; + while (segments.length) { + const next: Entity = current[segments.shift() !]; + if (next === undefined) { + return null; + } + if (segments.length > 0 && (!isFolder(next))) { + return null; + } + if (isFile(next)) { + return next; + } + if (isSymLink(next)) { + return followSymLinks ? + this.findFromPath(AbsoluteFsPath.resolve(next.path, ...segments), {followSymLinks}) : + next; + } + current = next; + } + return current || null; + } + + private splitIntoFolderAndFile(path: AbsoluteFsPath): [AbsoluteFsPath, string] { + const segments = path.split('/'); + const file = segments.pop() !; + return [AbsoluteFsPath.fromUnchecked(segments.join('/')), file]; + } +} + +export type Entity = Folder | File | SymLink; +export interface Folder { [pathSegments: string]: Entity; } +export type File = string; +export class SymLink { + constructor(public path: AbsoluteFsPath) {} +} + +class MockFileStats implements FileStats { + constructor(private entity: Entity) {} + isFile(): boolean { return isFile(this.entity); } + isDirectory(): boolean { return isFolder(this.entity); } + isSymbolicLink(): boolean { return isSymLink(this.entity); } +} + +class MockFileSystemError extends Error { + constructor(public code: string, public path: string, message: string) { super(message); } +} + +function isFile(item: Entity | null): item is File { + return typeof item === 'string'; +} + +function isSymLink(item: Entity | null): item is SymLink { + return item instanceof SymLink; +} + +function isFolder(item: Entity | null): item is Folder { + return item !== null && !isFile(item) && !isSymLink(item); +} diff --git a/packages/compiler-cli/ngcc/test/helpers/utils.ts b/packages/compiler-cli/ngcc/test/helpers/utils.ts index 6969c51ea52f..f4eae77e54e0 100644 --- a/packages/compiler-cli/ngcc/test/helpers/utils.ts +++ b/packages/compiler-cli/ngcc/test/helpers/utils.ts @@ -12,9 +12,11 @@ import {makeProgram} from '../../../src/ngtsc/testing/in_memory_typescript'; import {BundleProgram} from '../../src/packages/bundle_program'; import {EntryPointFormat, EntryPointJsonProperty} from '../../src/packages/entry_point'; import {EntryPointBundle} from '../../src/packages/entry_point_bundle'; +import {Folder} from './mock_file_system'; export {getDeclaration} from '../../../src/ngtsc/testing/in_memory_typescript'; +const _ = AbsoluteFsPath.fromUnchecked; /** * * @param format The format of the bundle. @@ -28,11 +30,7 @@ export function makeTestEntryPointBundle( const src = makeTestBundleProgram(files); const dts = dtsFiles ? makeTestBundleProgram(dtsFiles) : null; const isFlatCore = isCore && src.r3SymbolsFile === null; - return { - formatProperty, - format, - rootDirs: [AbsoluteFsPath.fromUnchecked('/')], src, dts, isCore, isFlatCore - }; + return {formatProperty, format, rootDirs: [_('/')], src, dts, isCore, isFlatCore}; } /** @@ -41,10 +39,10 @@ export function makeTestEntryPointBundle( */ export function makeTestBundleProgram(files: {name: string, contents: string}[]): BundleProgram { const {program, options, host} = makeTestProgramInternal(...files); - const path = files[0].name; + const path = _(files[0].name); const file = program.getSourceFile(path) !; const r3SymbolsInfo = files.find(file => file.name.indexOf('r3_symbols') !== -1) || null; - const r3SymbolsPath = r3SymbolsInfo && r3SymbolsInfo.name; + const r3SymbolsPath = r3SymbolsInfo && _(r3SymbolsInfo.name); const r3SymbolsFile = r3SymbolsPath && program.getSourceFile(r3SymbolsPath) || null; return {program, options, host, path, file, r3SymbolsPath, r3SymbolsFile}; } @@ -124,3 +122,11 @@ export function convertToDirectTsLibImport(filesystem: {name: string, contents: return {...file, contents}; }); } + +export function createFileSystemFromProgramFiles( + ...fileCollections: ({name: string, contents: string}[] | undefined)[]): Folder { + const folder: Folder = {}; + fileCollections.forEach( + files => files && files.forEach(file => folder[file.name] = file.contents)); + return folder; +} diff --git a/packages/compiler-cli/ngcc/test/host/esm2015_host_spec.ts b/packages/compiler-cli/ngcc/test/host/esm2015_host_spec.ts index 1e61fc583c77..98e20267ffae 100644 --- a/packages/compiler-cli/ngcc/test/host/esm2015_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm2015_host_spec.ts @@ -8,7 +8,7 @@ import * as ts from 'typescript'; -import {ClassMemberKind, Import, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, CtorParameter, Import, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {MockLogger} from '../helpers/mock_logger'; import {getDeclaration, makeTestBundleProgram, makeTestProgram} from '../helpers/utils'; @@ -1007,7 +1007,7 @@ describe('Esm2015ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode) !; expect(parameters.length).toBe(1); - expect(parameters[0]).toEqual(jasmine.objectContaining({ + expect(parameters[0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: [], })); @@ -1021,7 +1021,7 @@ describe('Esm2015ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode) !; expect(parameters.length).toBe(1); - expect(parameters[0]).toEqual(jasmine.objectContaining({ + expect(parameters[0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: null, })); @@ -1035,7 +1035,7 @@ describe('Esm2015ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode) !; expect(parameters.length).toBe(1); - expect(parameters[0]).toEqual(jasmine.objectContaining({ + expect(parameters[0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: null, })); @@ -1115,11 +1115,11 @@ describe('Esm2015ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode); expect(parameters !.length).toBe(2); - expect(parameters ![0]).toEqual(jasmine.objectContaining({ + expect(parameters ![0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: null, })); - expect(parameters ![1]).toEqual(jasmine.objectContaining({ + expect(parameters ![1]).toEqual(jasmine.objectContaining({ name: 'arg2', decorators: jasmine.any(Array) as any })); diff --git a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts index 6f5430b93b6b..657b299b316b 100644 --- a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts @@ -8,7 +8,7 @@ import * as ts from 'typescript'; -import {ClassMemberKind, Decorator, Import, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, CtorParameter, Decorator, Import, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {Esm5ReflectionHost, getIifeBody} from '../../src/host/esm5_host'; import {MockLogger} from '../helpers/mock_logger'; @@ -1184,7 +1184,7 @@ describe('Esm5ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode); expect(parameters !.length).toBe(1); - expect(parameters ![0]).toEqual(jasmine.objectContaining({ + expect(parameters ![0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: null, })); @@ -1200,11 +1200,11 @@ describe('Esm5ReflectionHost', () => { const parameters = host.getConstructorParameters(classNode); expect(parameters !.length).toBe(2); - expect(parameters ![0]).toEqual(jasmine.objectContaining({ + expect(parameters ![0]).toEqual(jasmine.objectContaining({ name: 'arg1', decorators: null, })); - expect(parameters ![1]).toEqual(jasmine.objectContaining({ + expect(parameters ![1]).toEqual(jasmine.objectContaining({ name: 'arg2', decorators: jasmine.any(Array) as any })); diff --git a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts index 6b72e3a47d89..b6a54aaa2c47 100644 --- a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts +++ b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts @@ -9,10 +9,9 @@ import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/path'; import {existsSync, readFileSync, readdirSync, statSync, writeFileSync} from 'fs'; import * as mockFs from 'mock-fs'; -import {join} from 'path'; -const Module = require('module'); import {getAngularPackagesFromRunfiles, resolveNpmTreeArtifact} from '../../../test/runfile_helpers'; +import {NodeJSFileSystem} from '../../src/file_system/node_js_file_system'; import {mainNgcc} from '../../src/main'; import {markAsProcessed} from '../../src/packages/build_marker'; import {EntryPointJsonProperty, EntryPointPackageJson, SUPPORTED_FORMAT_PROPERTIES} from '../../src/packages/entry_point'; @@ -40,19 +39,7 @@ describe('ngcc main()', () => { describe('with targetEntryPointPath', () => { it('should only compile the given package entry-point (and its dependencies).', () => { - mainNgcc({basePath: '/node_modules', targetEntryPointPath: '@angular/common/http'}); - - expect(loadPackage('@angular/common/http').__processed_by_ivy_ngcc__).toEqual({ - module: '0.0.0-PLACEHOLDER', - es2015: '0.0.0-PLACEHOLDER', - esm5: '0.0.0-PLACEHOLDER', - esm2015: '0.0.0-PLACEHOLDER', - fesm5: '0.0.0-PLACEHOLDER', - fesm2015: '0.0.0-PLACEHOLDER', - typings: '0.0.0-PLACEHOLDER', - }); - // * `common` is a dependency of `common/http`, so is compiled. - expect(loadPackage('@angular/common').__processed_by_ivy_ngcc__).toEqual({ + const STANDARD_MARKERS = { module: '0.0.0-PLACEHOLDER', es2015: '0.0.0-PLACEHOLDER', esm5: '0.0.0-PLACEHOLDER', @@ -60,19 +47,19 @@ describe('ngcc main()', () => { fesm5: '0.0.0-PLACEHOLDER', fesm2015: '0.0.0-PLACEHOLDER', typings: '0.0.0-PLACEHOLDER', - }); - // * `core` is a dependency of `common`, so is compiled. - expect(loadPackage('@angular/core').__processed_by_ivy_ngcc__).toEqual({ - module: '0.0.0-PLACEHOLDER', - es2015: '0.0.0-PLACEHOLDER', - esm5: '0.0.0-PLACEHOLDER', - esm2015: '0.0.0-PLACEHOLDER', - fesm5: '0.0.0-PLACEHOLDER', - fesm2015: '0.0.0-PLACEHOLDER', - typings: '0.0.0-PLACEHOLDER', - }); - - // * `common/testing` is not a dependency of `common/http` so is not compiled. + }; + + mainNgcc({basePath: '/node_modules', targetEntryPointPath: '@angular/common/http/testing'}); + expect(loadPackage('@angular/common/http/testing').__processed_by_ivy_ngcc__) + .toEqual(STANDARD_MARKERS); + // * `common/http` is a dependency of `common/http/testing`, so is compiled. + expect(loadPackage('@angular/common/http').__processed_by_ivy_ngcc__) + .toEqual(STANDARD_MARKERS); + // * `core` is a dependency of `common/http`, so is compiled. + expect(loadPackage('@angular/core').__processed_by_ivy_ngcc__).toEqual(STANDARD_MARKERS); + // * `common` is a private (only in .js not .d.ts) dependency so is compiled. + expect(loadPackage('@angular/common').__processed_by_ivy_ngcc__).toEqual(STANDARD_MARKERS); + // * `common/testing` is not a dependency so is not compiled. expect(loadPackage('@angular/common/testing').__processed_by_ivy_ngcc__).toBeUndefined(); }); @@ -154,11 +141,13 @@ describe('ngcc main()', () => { function markPropertiesAsProcessed(packagePath: string, properties: EntryPointJsonProperty[]) { - const basePath = '/node_modules'; - const targetPackageJsonPath = _(join(basePath, packagePath, 'package.json')); + const basePath = _('/node_modules'); + const targetPackageJsonPath = AbsoluteFsPath.join(basePath, packagePath, 'package.json'); const targetPackage = loadPackage(packagePath); - markAsProcessed(targetPackage, targetPackageJsonPath, 'typings'); - properties.forEach(property => markAsProcessed(targetPackage, targetPackageJsonPath, property)); + const fs = new NodeJSFileSystem(); + markAsProcessed(fs, targetPackage, targetPackageJsonPath, 'typings'); + properties.forEach( + property => markAsProcessed(fs, targetPackage, targetPackageJsonPath, property)); } @@ -322,6 +311,24 @@ describe('ngcc main()', () => { expect(logger.logs.info).toContain(['Compiling @angular/common/http : esm2015 as esm2015']); }); }); + + describe('with pathMappings', () => { + it('should find and compile packages accessible via the pathMappings', () => { + mainNgcc({ + basePath: '/node_modules', + propertiesToConsider: ['es2015'], + pathMappings: {paths: {'*': ['dist/*']}, baseUrl: '/'}, + }); + expect(loadPackage('@angular/core').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + expect(loadPackage('local-package', '/dist').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + }); + }); }); @@ -332,13 +339,26 @@ function createMockFileSystem() { '/node_modules/tslib': loadDirectory(resolveNpmTreeArtifact('tslib', 'tslib.js')), '/node_modules/test-package': { 'package.json': '{"name": "test-package", "es2015": "./index.js", "typings": "./index.d.ts"}', + // no metadata.json file so not compiled by Angular. 'index.js': - 'import {AppModule} from "@angular/common"; export class MyApp extends AppModule;', - 'index.d.s': + 'import {AppModule} from "@angular/common"; export class MyApp extends AppModule {};', + 'index.d.ts': 'import {AppModule} from "@angular/common"; export declare class MyApp extends AppModule;', - } + }, + '/dist/local-package': { + 'package.json': + '{"name": "local-package", "es2015": "./index.js", "typings": "./index.d.ts"}', + 'index.metadata.json': 'DUMMY DATA', + 'index.js': ` + import {Component} from '@angular/core'; + export class AppComponent {}; + AppComponent.decorators = [ + { type: Component, args: [{selector: 'app', template: '

Hello

'}] } + ];`, + 'index.d.ts': ` + export declare class AppComponent {};`, + }, }); - spyOn(Module, '_resolveFilename').and.callFake(mockResolve); } function restoreRealFileSystem() { @@ -365,7 +385,7 @@ function loadDirectory(directoryPath: string): Directory { const directory: Directory = {}; readdirSync(directoryPath).forEach(item => { - const itemPath = join(directoryPath, item); + const itemPath = AbsoluteFsPath.resolve(directoryPath, item); if (statSync(itemPath).isDirectory()) { directory[item] = loadDirectory(itemPath); } else { @@ -380,37 +400,6 @@ interface Directory { [pathSegment: string]: string|Directory; } -/** - * A mock implementation of the node.js Module._resolveFilename function, - * which we are spying on to support mocking out the file-system in these tests. - * - * @param request the path to a module that needs resolving. - */ -function mockResolve(request: string): string|null { - if (existsSync(request)) { - const stat = statSync(request); - if (stat.isFile()) { - return request; - } else if (stat.isDirectory()) { - const pIndex = mockResolve(request + '/index'); - if (pIndex && existsSync(pIndex)) { - return pIndex; - } - } - } - for (const ext of ['.js', '.d.ts']) { - if (existsSync(request + ext)) { - return request + ext; - } - } - if (request.indexOf('/node_modules') === 0) { - // We already tried adding node_modules so give up. - return null; - } else { - return mockResolve(join('/node_modules', request)); - } -} - -function loadPackage(packageName: string): EntryPointPackageJson { - return JSON.parse(readFileSync(`/node_modules/${packageName}/package.json`, 'utf8')); +function loadPackage(packageName: string, basePath = '/node_modules'): EntryPointPackageJson { + return JSON.parse(readFileSync(`${basePath}/${packageName}/package.json`, 'utf8')); } diff --git a/packages/compiler-cli/ngcc/test/packages/build_marker_spec.ts b/packages/compiler-cli/ngcc/test/packages/build_marker_spec.ts index d57061cc2dd6..8e12ce8d37d2 100644 --- a/packages/compiler-cli/ngcc/test/packages/build_marker_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/build_marker_spec.ts @@ -5,16 +5,12 @@ * 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 {readFileSync, writeFileSync} from 'fs'; -import * as mockFs from 'mock-fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {hasBeenProcessed, markAsProcessed} from '../../src/packages/build_marker'; -import {EntryPoint} from '../../src/packages/entry_point'; +import {MockFileSystem} from '../helpers/mock_file_system'; function createMockFileSystem() { - mockFs({ + return new MockFileSystem({ '/node_modules/@angular/common': { 'package.json': `{ "fesm2015": "./fesm2015/common.js", @@ -90,37 +86,33 @@ function createMockFileSystem() { }); } -function restoreRealFileSystem() { - mockFs.restore(); -} - describe('Marker files', () => { - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); - const COMMON_PACKAGE_PATH = AbsoluteFsPath.from('/node_modules/@angular/common/package.json'); describe('markAsProcessed', () => { it('should write a property in the package.json containing the version placeholder', () => { - let pkg = JSON.parse(readFileSync(COMMON_PACKAGE_PATH, 'utf8')); + const fs = createMockFileSystem(); + + let pkg = JSON.parse(fs.readFile(COMMON_PACKAGE_PATH)); expect(pkg.__processed_by_ivy_ngcc__).toBeUndefined(); expect(pkg.__processed_by_ivy_ngcc__).toBeUndefined(); - markAsProcessed(pkg, COMMON_PACKAGE_PATH, 'fesm2015'); - pkg = JSON.parse(readFileSync(COMMON_PACKAGE_PATH, 'utf8')); + markAsProcessed(fs, pkg, COMMON_PACKAGE_PATH, 'fesm2015'); + pkg = JSON.parse(fs.readFile(COMMON_PACKAGE_PATH)); expect(pkg.__processed_by_ivy_ngcc__.fesm2015).toEqual('0.0.0-PLACEHOLDER'); expect(pkg.__processed_by_ivy_ngcc__.esm5).toBeUndefined(); - markAsProcessed(pkg, COMMON_PACKAGE_PATH, 'esm5'); - pkg = JSON.parse(readFileSync(COMMON_PACKAGE_PATH, 'utf8')); + markAsProcessed(fs, pkg, COMMON_PACKAGE_PATH, 'esm5'); + pkg = JSON.parse(fs.readFile(COMMON_PACKAGE_PATH)); expect(pkg.__processed_by_ivy_ngcc__.fesm2015).toEqual('0.0.0-PLACEHOLDER'); expect(pkg.__processed_by_ivy_ngcc__.esm5).toEqual('0.0.0-PLACEHOLDER'); }); it('should update the packageJson object in-place', () => { - let pkg = JSON.parse(readFileSync(COMMON_PACKAGE_PATH, 'utf8')); + const fs = createMockFileSystem(); + let pkg = JSON.parse(fs.readFile(COMMON_PACKAGE_PATH)); expect(pkg.__processed_by_ivy_ngcc__).toBeUndefined(); - markAsProcessed(pkg, COMMON_PACKAGE_PATH, 'fesm2015'); + markAsProcessed(fs, pkg, COMMON_PACKAGE_PATH, 'fesm2015'); expect(pkg.__processed_by_ivy_ngcc__.fesm2015).toEqual('0.0.0-PLACEHOLDER'); }); }); diff --git a/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts b/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts deleted file mode 100644 index 0a6889bff3af..000000000000 --- a/packages/compiler-cli/ngcc/test/packages/dependency_host_spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * @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 path from 'canonical-path'; -import * as mockFs from 'mock-fs'; -import * as ts from 'typescript'; - -import {AbsoluteFsPath, PathSegment} from '../../../src/ngtsc/path'; -import {DependencyHost} from '../../src/packages/dependency_host'; -const Module = require('module'); - -interface DepMap { - [path: string]: {resolved: string[], missing: string[]}; -} - -const _ = AbsoluteFsPath.from; - -describe('DependencyHost', () => { - let host: DependencyHost; - beforeEach(() => host = new DependencyHost()); - - describe('getDependencies()', () => { - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); - - 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()); - 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); - }); - - 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); - }); - - 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); - expect(missing.size).toBe(1); - expect(missing.has('missing')).toBe(true); - expect(deepImports.size).toBe(0); - }); - - it('should not register deep imports as missing', () => { - // 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); - expect(missing.size).toBe(0); - expect(deepImports.size).toBe(1); - expect(deepImports.has('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); - }); - - - 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); - }); - - 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';`, - }); - } - }); - - 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); - }); - }); - - describe('isStringImportOrReexport', () => { - it('should return true if the statement is an import', () => { - expect(host.isStringImportOrReexport(createStatement('import {X} from "some/x";'))) - .toBe(true); - expect(host.isStringImportOrReexport(createStatement('import * as X from "some/x";'))) - .toBe(true); - }); - - it('should return true if the statement is a re-export', () => { - expect(host.isStringImportOrReexport(createStatement('export {X} from "some/x";'))) - .toBe(true); - expect(host.isStringImportOrReexport(createStatement('export * from "some/x";'))).toBe(true); - }); - - it('should return false if the statement is not an import or a re-export', () => { - expect(host.isStringImportOrReexport(createStatement('class X {}'))).toBe(false); - expect(host.isStringImportOrReexport(createStatement('export function foo() {}'))) - .toBe(false); - expect(host.isStringImportOrReexport(createStatement('export const X = 10;'))).toBe(false); - }); - - function createStatement(source: string) { - return ts - .createSourceFile('source.js', source, ts.ScriptTarget.ES2015, false, ts.ScriptKind.JS) - .statements[0]; - } - }); - - describe('hasImportOrReexportStatements', () => { - it('should return true if there is an import statement', () => { - expect(host.hasImportOrReexportStatements('import {X} from "some/x";')).toBe(true); - expect(host.hasImportOrReexportStatements('import * as X from "some/x";')).toBe(true); - expect( - host.hasImportOrReexportStatements('blah blah\n\n import {X} from "some/x";\nblah blah')) - .toBe(true); - expect(host.hasImportOrReexportStatements('\t\timport {X} from "some/x";')).toBe(true); - }); - it('should return true if there is a re-export statement', () => { - expect(host.hasImportOrReexportStatements('export {X} from "some/x";')).toBe(true); - expect( - host.hasImportOrReexportStatements('blah blah\n\n export {X} from "some/x";\nblah blah')) - .toBe(true); - expect(host.hasImportOrReexportStatements('\t\texport {X} from "some/x";')).toBe(true); - expect(host.hasImportOrReexportStatements( - 'blah blah\n\n export * from "@angular/core;\nblah blah')) - .toBe(true); - }); - it('should return false if there is no import nor re-export statement', () => { - expect(host.hasImportOrReexportStatements('blah blah')).toBe(false); - expect(host.hasImportOrReexportStatements('export function moo() {}')).toBe(false); - expect( - host.hasImportOrReexportStatements('Some text that happens to include the word import')) - .toBe(false); - }); - }); - - function restoreRealFileSystem() { mockFs.restore(); } -}); 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 92967be7702a..e93af3918566 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 @@ -6,13 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import * as mockFs from 'mock-fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; -import {DependencyHost} from '../../src/packages/dependency_host'; -import {DependencyResolver} from '../../src/packages/dependency_resolver'; +import {DependencyResolver} from '../../src/dependencies/dependency_resolver'; +import {EsmDependencyHost} from '../../src/dependencies/esm_dependency_host'; +import {ModuleResolver} from '../../src/dependencies/module_resolver'; import {EntryPoint} from '../../src/packages/entry_point'; import {EntryPointFinder} from '../../src/packages/entry_point_finder'; +import {MockFileSystem, SymLink} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; const _ = AbsoluteFsPath.from; @@ -21,14 +21,14 @@ describe('findEntryPoints()', () => { let resolver: DependencyResolver; let finder: EntryPointFinder; beforeEach(() => { - resolver = new DependencyResolver(new MockLogger(), new DependencyHost()); + const fs = createMockFileSystem(); + resolver = + new DependencyResolver(new MockLogger(), new EsmDependencyHost(fs, new ModuleResolver(fs))); spyOn(resolver, 'sortEntryPointsByDependency').and.callFake((entryPoints: EntryPoint[]) => { return {entryPoints, ignoredEntryPoints: [], ignoredDependencies: []}; }); - finder = new EntryPointFinder(new MockLogger(), resolver); + finder = new EntryPointFinder(fs, new MockLogger(), resolver); }); - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); it('should find sub-entry-points within a package', () => { const {entryPoints} = finder.findEntryPoints(_('/sub_entry_points')); @@ -86,7 +86,7 @@ describe('findEntryPoints()', () => { }); function createMockFileSystem() { - mockFs({ + return new MockFileSystem({ '/sub_entry_points': { 'common': { 'package.json': createPackageJson('common'), @@ -138,7 +138,7 @@ describe('findEntryPoints()', () => { }, }, '/symlinked_folders': { - 'common': mockFs.symlink({path: '/sub_entry_points/common'}), + 'common': new SymLink(_('/sub_entry_points/common')), }, '/nested_node_modules': { 'outer': { @@ -154,7 +154,6 @@ describe('findEntryPoints()', () => { }, }); } - function restoreRealFileSystem() { mockFs.restore(); } }); function createPackageJson(packageName: string): string { diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts index c360d80dfd59..375fa013ac81 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts @@ -6,90 +6,99 @@ * found in the LICENSE file at https://angular.io/license */ -import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/path'; -import {readFileSync} from 'fs'; -import * as mockFs from 'mock-fs'; - +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../../src/file_system/file_system'; import {getEntryPointInfo} from '../../src/packages/entry_point'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; -describe('getEntryPointInfo()', () => { - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); +const _ = AbsoluteFsPath.fromUnchecked; - const _ = AbsoluteFsPath.from; +describe('getEntryPointInfo()', () => { const SOME_PACKAGE = _('/some_package'); it('should return an object containing absolute paths to the formats of the specified entry-point', () => { - const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/valid_entry_point')); + const fs = createMockFileSystem(); + const entryPoint = getEntryPointInfo( + fs, new MockLogger(), SOME_PACKAGE, _('/some_package/valid_entry_point')); expect(entryPoint).toEqual({ name: 'some-package/valid_entry_point', package: SOME_PACKAGE, path: _('/some_package/valid_entry_point'), typings: _(`/some_package/valid_entry_point/valid_entry_point.d.ts`), - packageJson: loadPackageJson('/some_package/valid_entry_point'), + packageJson: loadPackageJson(fs, '/some_package/valid_entry_point'), + compiledByAngular: true, }); }); it('should return null if there is no package.json at the entry-point path', () => { - const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/missing_package_json')); + const fs = createMockFileSystem(); + const entryPoint = getEntryPointInfo( + fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_package_json')); expect(entryPoint).toBe(null); }); it('should return null if there is no typings or types field in the package.json', () => { + const fs = createMockFileSystem(); const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/missing_typings')); + getEntryPointInfo(fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_typings')); expect(entryPoint).toBe(null); }); - it('should return null if there is no esm2015 nor fesm2015 field in the package.json', () => { - const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/missing_esm2015')); - expect(entryPoint).toBe(null); - }); - - it('should return null if there is no metadata.json file next to the typing file', () => { - const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/missing_metadata.json')); - expect(entryPoint).toBe(null); - }); + it('should return an object with `compiledByAngular` set to false if there is no metadata.json file next to the typing file', + () => { + const fs = createMockFileSystem(); + const entryPoint = getEntryPointInfo( + fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_metadata')); + expect(entryPoint).toEqual({ + name: 'some-package/missing_metadata', + package: SOME_PACKAGE, + path: _('/some_package/missing_metadata'), + typings: _(`/some_package/missing_metadata/missing_metadata.d.ts`), + packageJson: loadPackageJson(fs, '/some_package/missing_metadata'), + compiledByAngular: false, + }); + }); it('should work if the typings field is named `types', () => { + const fs = createMockFileSystem(); const entryPoint = getEntryPointInfo( - new MockLogger(), SOME_PACKAGE, _('/some_package/types_rather_than_typings')); + fs, new MockLogger(), SOME_PACKAGE, _('/some_package/types_rather_than_typings')); expect(entryPoint).toEqual({ name: 'some-package/types_rather_than_typings', package: SOME_PACKAGE, path: _('/some_package/types_rather_than_typings'), typings: _(`/some_package/types_rather_than_typings/types_rather_than_typings.d.ts`), - packageJson: loadPackageJson('/some_package/types_rather_than_typings'), + packageJson: loadPackageJson(fs, '/some_package/types_rather_than_typings'), + compiledByAngular: true, }); }); it('should work with Angular Material style package.json', () => { + const fs = createMockFileSystem(); const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/material_style')); + getEntryPointInfo(fs, new MockLogger(), SOME_PACKAGE, _('/some_package/material_style')); expect(entryPoint).toEqual({ name: 'some_package/material_style', package: SOME_PACKAGE, path: _('/some_package/material_style'), typings: _(`/some_package/material_style/material_style.d.ts`), - packageJson: JSON.parse(readFileSync('/some_package/material_style/package.json', 'utf8')), + packageJson: loadPackageJson(fs, '/some_package/material_style'), + compiledByAngular: true, }); }); it('should return null if the package.json is not valid JSON', () => { - const entryPoint = - getEntryPointInfo(new MockLogger(), SOME_PACKAGE, _('/some_package/unexpected_symbols')); + const fs = createMockFileSystem(); + const entryPoint = getEntryPointInfo( + fs, new MockLogger(), SOME_PACKAGE, _('/some_package/unexpected_symbols')); expect(entryPoint).toBe(null); }); }); function createMockFileSystem() { - mockFs({ + return new MockFileSystem({ '/some_package': { 'valid_entry_point': { 'package.json': createPackageJson('valid_entry_point'), @@ -136,10 +145,6 @@ function createMockFileSystem() { }); } -function restoreRealFileSystem() { - mockFs.restore(); -} - function createPackageJson( packageName: string, {excludes}: {excludes?: string[]} = {}, typingsProp: string = 'typings'): string { @@ -158,6 +163,6 @@ function createPackageJson( return JSON.stringify(packageJson); } -export function loadPackageJson(packagePath: string) { - return JSON.parse(readFileSync(packagePath + '/package.json', 'utf8')); +export function loadPackageJson(fs: FileSystem, packagePath: string) { + return JSON.parse(fs.readFile(_(packagePath + '/package.json'))); } diff --git a/packages/compiler-cli/ngcc/test/rendering/esm2015_renderer_spec.ts b/packages/compiler-cli/ngcc/test/rendering/esm2015_renderer_spec.ts index d36d5838f691..34ba2583bfe5 100644 --- a/packages/compiler-cli/ngcc/test/rendering/esm2015_renderer_spec.ts +++ b/packages/compiler-cli/ngcc/test/rendering/esm2015_renderer_spec.ts @@ -5,7 +5,6 @@ * 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 {dirname} from 'canonical-path'; import MagicString from 'magic-string'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../../src/ngtsc/path'; @@ -15,22 +14,24 @@ import {SwitchMarkerAnalyzer} from '../../src/analysis/switch_marker_analyzer'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {EsmRenderer} from '../../src/rendering/esm_renderer'; import {makeTestEntryPointBundle} from '../helpers/utils'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; -function setup(file: {name: string, contents: string}) { +const _ = AbsoluteFsPath.fromUnchecked; + +function setup(file: {name: AbsoluteFsPath, contents: string}) { + const fs = new MockFileSystem(); const logger = new MockLogger(); - const dir = dirname(file.name); const bundle = makeTestEntryPointBundle('es2015', 'esm2015', false, [file]) !; const typeChecker = bundle.src.program.getTypeChecker(); const host = new Esm2015ReflectionHost(logger, false, typeChecker); const referencesRegistry = new NgccReferencesRegistry(host); - const decorationAnalyses = - new DecorationAnalyzer( - bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, host, - referencesRegistry, [AbsoluteFsPath.fromUnchecked('/')], false) - .analyzeProgram(); + const decorationAnalyses = new DecorationAnalyzer( + fs, bundle.src.program, bundle.src.options, bundle.src.host, + typeChecker, host, referencesRegistry, [_('/')], false) + .analyzeProgram(); const switchMarkerAnalyses = new SwitchMarkerAnalyzer(host).analyzeProgram(bundle.src.program); - const renderer = new EsmRenderer(logger, host, false, bundle, dir); + const renderer = new EsmRenderer(fs, logger, host, false, bundle); return { host, program: bundle.src.program, @@ -39,7 +40,7 @@ function setup(file: {name: string, contents: string}) { } const PROGRAM = { - name: '/some/file.js', + name: _('/some/file.js'), contents: ` /* A copyright notice */ import 'some-side-effect'; @@ -75,7 +76,7 @@ function compileNgModuleFactory__POST_R3__(injector, options, moduleType) { }; const PROGRAM_DECORATE_HELPER = { - name: '/some/file.js', + name: _('/some/file.js'), contents: ` import * as tslib_1 from "tslib"; var D_1; @@ -137,10 +138,10 @@ import * as i1 from '@angular/common';`); it('should insert the given exports at the end of the source file', () => { const {renderer} = setup(PROGRAM); const output = new MagicString(PROGRAM.contents); - renderer.addExports(output, PROGRAM.name.replace(/\.js$/, ''), [ - {from: '/some/a.js', dtsFrom: '/some/a.d.ts', identifier: 'ComponentA1'}, - {from: '/some/a.js', dtsFrom: '/some/a.d.ts', identifier: 'ComponentA2'}, - {from: '/some/foo/b.js', dtsFrom: '/some/foo/b.d.ts', identifier: 'ComponentB'}, + renderer.addExports(output, _(PROGRAM.name.replace(/\.js$/, '')), [ + {from: _('/some/a.js'), dtsFrom: _('/some/a.d.ts'), identifier: 'ComponentA1'}, + {from: _('/some/a.js'), dtsFrom: _('/some/a.d.ts'), identifier: 'ComponentA2'}, + {from: _('/some/foo/b.js'), dtsFrom: _('/some/foo/b.d.ts'), identifier: 'ComponentB'}, {from: PROGRAM.name, dtsFrom: PROGRAM.name, identifier: 'TopLevelComponent'}, ]); expect(output.toString()).toContain(` @@ -154,10 +155,10 @@ export {TopLevelComponent};`); it('should not insert alias exports in js output', () => { const {renderer} = setup(PROGRAM); const output = new MagicString(PROGRAM.contents); - renderer.addExports(output, PROGRAM.name.replace(/\.js$/, ''), [ - {from: '/some/a.js', alias: 'eComponentA1', identifier: 'ComponentA1'}, - {from: '/some/a.js', alias: 'eComponentA2', identifier: 'ComponentA2'}, - {from: '/some/foo/b.js', alias: 'eComponentB', identifier: 'ComponentB'}, + renderer.addExports(output, _(PROGRAM.name.replace(/\.js$/, '')), [ + {from: _('/some/a.js'), alias: 'eComponentA1', identifier: 'ComponentA1'}, + {from: _('/some/a.js'), alias: 'eComponentA2', identifier: 'ComponentA2'}, + {from: _('/some/foo/b.js'), alias: 'eComponentB', identifier: 'ComponentB'}, {from: PROGRAM.name, alias: 'eTopLevelComponent', identifier: 'TopLevelComponent'}, ]); const outputString = output.toString(); diff --git a/packages/compiler-cli/ngcc/test/rendering/esm5_renderer_spec.ts b/packages/compiler-cli/ngcc/test/rendering/esm5_renderer_spec.ts index a7d7c17614b4..83c8b253cf0c 100644 --- a/packages/compiler-cli/ngcc/test/rendering/esm5_renderer_spec.ts +++ b/packages/compiler-cli/ngcc/test/rendering/esm5_renderer_spec.ts @@ -5,7 +5,6 @@ * 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 {dirname} from 'canonical-path'; import MagicString from 'magic-string'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../../src/ngtsc/path'; @@ -15,22 +14,25 @@ import {SwitchMarkerAnalyzer} from '../../src/analysis/switch_marker_analyzer'; import {Esm5ReflectionHost} from '../../src/host/esm5_host'; import {Esm5Renderer} from '../../src/rendering/esm5_renderer'; import {makeTestEntryPointBundle, getDeclaration} from '../helpers/utils'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; -function setup(file: {name: string, contents: string}) { +const _ = AbsoluteFsPath.fromUnchecked; + +function setup(file: {name: AbsoluteFsPath, contents: string}) { + const fs = new MockFileSystem(); const logger = new MockLogger(); - const dir = dirname(file.name); const bundle = makeTestEntryPointBundle('module', 'esm5', false, [file]); const typeChecker = bundle.src.program.getTypeChecker(); const host = new Esm5ReflectionHost(logger, false, typeChecker); const referencesRegistry = new NgccReferencesRegistry(host); const decorationAnalyses = new DecorationAnalyzer( - bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, host, + fs, bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, host, referencesRegistry, [AbsoluteFsPath.fromUnchecked('/')], false) .analyzeProgram(); const switchMarkerAnalyses = new SwitchMarkerAnalyzer(host).analyzeProgram(bundle.src.program); - const renderer = new Esm5Renderer(logger, host, false, bundle, dir); + const renderer = new Esm5Renderer(fs, logger, host, false, bundle); return { host, program: bundle.src.program, @@ -39,7 +41,7 @@ function setup(file: {name: string, contents: string}) { } const PROGRAM = { - name: '/some/file.js', + name: _('/some/file.js'), contents: ` /* A copyright notice */ import 'some-side-effect'; @@ -99,7 +101,7 @@ export {A, B, C, NoIife, BadIife};` }; const PROGRAM_DECORATE_HELPER = { - name: '/some/file.js', + name: _('/some/file.js'), contents: ` import * as tslib_1 from "tslib"; /* A copyright notice */ @@ -174,10 +176,10 @@ import * as i1 from '@angular/common';`); it('should insert the given exports at the end of the source file', () => { const {renderer} = setup(PROGRAM); const output = new MagicString(PROGRAM.contents); - renderer.addExports(output, PROGRAM.name.replace(/\.js$/, ''), [ - {from: '/some/a.js', dtsFrom: '/some/a.d.ts', identifier: 'ComponentA1'}, - {from: '/some/a.js', dtsFrom: '/some/a.d.ts', identifier: 'ComponentA2'}, - {from: '/some/foo/b.js', dtsFrom: '/some/foo/b.d.ts', identifier: 'ComponentB'}, + renderer.addExports(output, _(PROGRAM.name.replace(/\.js$/, '')), [ + {from: _('/some/a.js'), dtsFrom: _('/some/a.d.ts'), identifier: 'ComponentA1'}, + {from: _('/some/a.js'), dtsFrom: _('/some/a.d.ts'), identifier: 'ComponentA2'}, + {from: _('/some/foo/b.js'), dtsFrom: _('/some/foo/b.d.ts'), identifier: 'ComponentB'}, {from: PROGRAM.name, dtsFrom: PROGRAM.name, identifier: 'TopLevelComponent'}, ]); expect(output.toString()).toContain(` @@ -191,10 +193,10 @@ export {TopLevelComponent};`); it('should not insert alias exports in js output', () => { const {renderer} = setup(PROGRAM); const output = new MagicString(PROGRAM.contents); - renderer.addExports(output, PROGRAM.name.replace(/\.js$/, ''), [ - {from: '/some/a.js', alias: 'eComponentA1', identifier: 'ComponentA1'}, - {from: '/some/a.js', alias: 'eComponentA2', identifier: 'ComponentA2'}, - {from: '/some/foo/b.js', alias: 'eComponentB', identifier: 'ComponentB'}, + renderer.addExports(output, _(PROGRAM.name.replace(/\.js$/, '')), [ + {from: _('/some/a.js'), alias: _('eComponentA1'), identifier: 'ComponentA1'}, + {from: _('/some/a.js'), alias: _('eComponentA2'), identifier: 'ComponentA2'}, + {from: _('/some/foo/b.js'), alias: _('eComponentB'), identifier: 'ComponentB'}, {from: PROGRAM.name, alias: 'eTopLevelComponent', identifier: 'TopLevelComponent'}, ]); const outputString = output.toString(); @@ -284,14 +286,14 @@ SOME DEFINITION TEXT const noIifeDeclaration = getDeclaration(program, sourceFile.fileName, 'NoIife', ts.isFunctionDeclaration); - const mockNoIifeClass: any = {declaration: noIifeDeclaration, name: 'NoIife'}; + const mockNoIifeClass: any = {declaration: noIifeDeclaration, name: _('NoIife')}; expect(() => renderer.addDefinitions(output, mockNoIifeClass, 'SOME DEFINITION TEXT')) .toThrowError( 'Compiled class declaration is not inside an IIFE: NoIife in /some/file.js'); const badIifeDeclaration = getDeclaration(program, sourceFile.fileName, 'BadIife', ts.isVariableDeclaration); - const mockBadIifeClass: any = {declaration: badIifeDeclaration, name: 'BadIife'}; + const mockBadIifeClass: any = {declaration: badIifeDeclaration, name: _('BadIife')}; expect(() => renderer.addDefinitions(output, mockBadIifeClass, 'SOME DEFINITION TEXT')) .toThrowError( 'Compiled class wrapper IIFE does not have a return statement: BadIife in /some/file.js'); diff --git a/packages/compiler-cli/ngcc/test/rendering/renderer_spec.ts b/packages/compiler-cli/ngcc/test/rendering/renderer_spec.ts index cea122f6af3a..f47ac9b248bc 100644 --- a/packages/compiler-cli/ngcc/test/rendering/renderer_spec.ts +++ b/packages/compiler-cli/ngcc/test/rendering/renderer_spec.ts @@ -5,10 +5,10 @@ * 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 MagicString from 'magic-string'; import * as ts from 'typescript'; import {fromObject, generateMapFileComment} from 'convert-source-map'; +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {CompiledClass, DecorationAnalyzer} from '../../src/analysis/decoration_analyzer'; import {NgccReferencesRegistry} from '../../src/analysis/ngcc_references_registry'; import {ModuleWithProvidersAnalyzer} from '../../src/analysis/module_with_providers_analyzer'; @@ -17,14 +17,19 @@ import {SwitchMarkerAnalyzer} from '../../src/analysis/switch_marker_analyzer'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; import {RedundantDecoratorMap, Renderer} from '../../src/rendering/renderer'; import {EntryPointBundle} from '../../src/packages/entry_point_bundle'; -import {makeTestEntryPointBundle} from '../helpers/utils'; +import {makeTestEntryPointBundle, createFileSystemFromProgramFiles} from '../helpers/utils'; import {Logger} from '../../src/logging/logger'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; +import {FileSystem} from '../../src/file_system/file_system'; + +const _ = AbsoluteFsPath.fromUnchecked; class TestRenderer extends Renderer { constructor( - logger: Logger, host: Esm2015ReflectionHost, isCore: boolean, bundle: EntryPointBundle) { - super(logger, host, isCore, bundle, '/src'); + fs: FileSystem, logger: Logger, host: Esm2015ReflectionHost, isCore: boolean, + bundle: EntryPointBundle) { + super(fs, logger, host, isCore, bundle); } addImports( output: MagicString, imports: {specifier: string, qualifier: string}[], sf: ts.SourceFile) { @@ -52,15 +57,17 @@ class TestRenderer extends Renderer { function createTestRenderer( packageName: string, files: {name: string, contents: string}[], - dtsFiles?: {name: string, contents: string}[]) { + dtsFiles?: {name: string, contents: string}[], + mappingFiles?: {name: string, contents: string}[]) { const logger = new MockLogger(); + const fs = new MockFileSystem(createFileSystemFromProgramFiles(files, dtsFiles, mappingFiles)); const isCore = packageName === '@angular/core'; const bundle = makeTestEntryPointBundle('es2015', 'esm2015', isCore, files, dtsFiles); const typeChecker = bundle.src.program.getTypeChecker(); const host = new Esm2015ReflectionHost(logger, isCore, typeChecker, bundle.dts); const referencesRegistry = new NgccReferencesRegistry(host); const decorationAnalyses = new DecorationAnalyzer( - bundle.src.program, bundle.src.options, bundle.src.host, + fs, bundle.src.program, bundle.src.options, bundle.src.host, typeChecker, host, referencesRegistry, bundle.rootDirs, isCore) .analyzeProgram(); const switchMarkerAnalyses = new SwitchMarkerAnalyzer(host).analyzeProgram(bundle.src.program); @@ -68,7 +75,7 @@ function createTestRenderer( new ModuleWithProvidersAnalyzer(host, referencesRegistry).analyzeProgram(bundle.src.program); const privateDeclarationsAnalyses = new PrivateDeclarationsAnalyzer(host, referencesRegistry).analyzeProgram(bundle.src.program); - const renderer = new TestRenderer(logger, host, isCore, bundle); + const renderer = new TestRenderer(fs, logger, host, isCore, bundle); spyOn(renderer, 'addImports').and.callThrough(); spyOn(renderer, 'addDefinitions').and.callThrough(); spyOn(renderer, 'removeDecorators').and.callThrough(); @@ -193,8 +200,8 @@ describe('Renderer', () => { const addDefinitionsSpy = renderer.addDefinitions as jasmine.Spy; expect(addDefinitionsSpy.calls.first().args[0].toString()).toEqual(RENDERED_CONTENTS); expect(addDefinitionsSpy.calls.first().args[1]).toEqual(jasmine.objectContaining({ - name: 'A', - decorators: [jasmine.objectContaining({name: 'Directive'})], + name: _('A'), + decorators: [jasmine.objectContaining({name: _('Directive')})] })); expect(addDefinitionsSpy.calls.first().args[2]) .toEqual( @@ -252,15 +259,15 @@ describe('Renderer', () => { it('should merge any external source map from the original file and write the output to an external source map', () => { - // Mock out reading the map file from disk - spyOn(fs, 'readFileSync').and.returnValue(INPUT_PROGRAM_MAP.toJSON()); + const sourceFiles = [{ + ...INPUT_PROGRAM, + contents: INPUT_PROGRAM.contents + '\n//# sourceMappingURL=file.js.map' + }]; + const mappingFiles = + [{name: INPUT_PROGRAM.name + '.map', contents: INPUT_PROGRAM_MAP.toJSON()}]; const {decorationAnalyses, renderer, switchMarkerAnalyses, privateDeclarationsAnalyses, moduleWithProvidersAnalyses} = - createTestRenderer( - 'test-package', [{ - ...INPUT_PROGRAM, - contents: INPUT_PROGRAM.contents + '\n//# sourceMappingURL=file.js.map' - }]); + createTestRenderer('test-package', sourceFiles, undefined, mappingFiles); const result = renderer.renderProgram( decorationAnalyses, switchMarkerAnalyses, privateDeclarationsAnalyses, moduleWithProvidersAnalyses); @@ -268,7 +275,7 @@ describe('Renderer', () => { expect(result[0].contents) .toEqual(RENDERED_CONTENTS + '\n' + generateMapFileComment('file.js.map')); expect(result[1].path).toEqual('/src/file.js.map'); - expect(result[1].contents).toEqual(MERGED_OUTPUT_PROGRAM_MAP.toJSON()); + expect(JSON.parse(result[1].contents)).toEqual(MERGED_OUTPUT_PROGRAM_MAP.toObject()); }); }); @@ -354,7 +361,7 @@ describe('Renderer', () => { // Add a mock export to trigger export rendering privateDeclarationsAnalyses.push( - {identifier: 'ComponentB', from: '/src/file.js', dtsFrom: '/typings/b.d.ts'}); + {identifier: 'ComponentB', from: _('/src/file.js'), dtsFrom: _('/typings/b.d.ts')}); const result = renderer.renderProgram( decorationAnalyses, switchMarkerAnalyses, privateDeclarationsAnalyses, 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 000000000000..4b44d7e6d2e3 --- /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); + }); +}); diff --git a/packages/compiler-cli/ngcc/test/writing/in_place_file_writer_spec.ts b/packages/compiler-cli/ngcc/test/writing/in_place_file_writer_spec.ts index 96dcb76d4aa1..f3c13a01dbaa 100644 --- a/packages/compiler-cli/ngcc/test/writing/in_place_file_writer_spec.ts +++ b/packages/compiler-cli/ngcc/test/writing/in_place_file_writer_spec.ts @@ -5,16 +5,16 @@ * 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 {existsSync, readFileSync} from 'fs'; -import * as mockFs from 'mock-fs'; - +import {AbsoluteFsPath} from '../../../src/ngtsc/path'; import {EntryPoint} from '../../src/packages/entry_point'; import {EntryPointBundle} from '../../src/packages/entry_point_bundle'; import {InPlaceFileWriter} from '../../src/writing/in_place_file_writer'; +import {MockFileSystem} from '../helpers/mock_file_system'; + +const _ = AbsoluteFsPath.fromUnchecked; function createMockFileSystem() { - mockFs({ + return new MockFileSystem({ '/package/path': { 'top-level.js': 'ORIGINAL TOP LEVEL', 'folder-1': { @@ -30,56 +30,52 @@ function createMockFileSystem() { }); } -function restoreRealFileSystem() { - mockFs.restore(); -} - describe('InPlaceFileWriter', () => { - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); - it('should write all the FileInfo to the disk', () => { - const fileWriter = new InPlaceFileWriter(); + const fs = createMockFileSystem(); + const fileWriter = new InPlaceFileWriter(fs); fileWriter.writeBundle({} as EntryPoint, {} as EntryPointBundle, [ - {path: '/package/path/top-level.js', contents: 'MODIFIED TOP LEVEL'}, - {path: '/package/path/folder-1/file-1.js', contents: 'MODIFIED FILE 1'}, - {path: '/package/path/folder-2/file-4.js', contents: 'MODIFIED FILE 4'}, - {path: '/package/path/folder-3/file-5.js', contents: 'NEW FILE 5'}, + {path: _('/package/path/top-level.js'), contents: 'MODIFIED TOP LEVEL'}, + {path: _('/package/path/folder-1/file-1.js'), contents: 'MODIFIED FILE 1'}, + {path: _('/package/path/folder-2/file-4.js'), contents: 'MODIFIED FILE 4'}, + {path: _('/package/path/folder-3/file-5.js'), contents: 'NEW FILE 5'}, ]); - expect(readFileSync('/package/path/top-level.js', 'utf8')).toEqual('MODIFIED TOP LEVEL'); - expect(readFileSync('/package/path/folder-1/file-1.js', 'utf8')).toEqual('MODIFIED FILE 1'); - expect(readFileSync('/package/path/folder-1/file-2.js', 'utf8')).toEqual('ORIGINAL FILE 2'); - expect(readFileSync('/package/path/folder-2/file-3.js', 'utf8')).toEqual('ORIGINAL FILE 3'); - expect(readFileSync('/package/path/folder-2/file-4.js', 'utf8')).toEqual('MODIFIED FILE 4'); - expect(readFileSync('/package/path/folder-3/file-5.js', 'utf8')).toEqual('NEW FILE 5'); + expect(fs.readFile(_('/package/path/top-level.js'))).toEqual('MODIFIED TOP LEVEL'); + expect(fs.readFile(_('/package/path/folder-1/file-1.js'))).toEqual('MODIFIED FILE 1'); + expect(fs.readFile(_('/package/path/folder-1/file-2.js'))).toEqual('ORIGINAL FILE 2'); + expect(fs.readFile(_('/package/path/folder-2/file-3.js'))).toEqual('ORIGINAL FILE 3'); + expect(fs.readFile(_('/package/path/folder-2/file-4.js'))).toEqual('MODIFIED FILE 4'); + expect(fs.readFile(_('/package/path/folder-3/file-5.js'))).toEqual('NEW FILE 5'); }); it('should create backups of all files that previously existed', () => { - const fileWriter = new InPlaceFileWriter(); + const fs = createMockFileSystem(); + const fileWriter = new InPlaceFileWriter(fs); fileWriter.writeBundle({} as EntryPoint, {} as EntryPointBundle, [ - {path: '/package/path/top-level.js', contents: 'MODIFIED TOP LEVEL'}, - {path: '/package/path/folder-1/file-1.js', contents: 'MODIFIED FILE 1'}, - {path: '/package/path/folder-2/file-4.js', contents: 'MODIFIED FILE 4'}, - {path: '/package/path/folder-3/file-5.js', contents: 'NEW FILE 5'}, + {path: _('/package/path/top-level.js'), contents: 'MODIFIED TOP LEVEL'}, + {path: _('/package/path/folder-1/file-1.js'), contents: 'MODIFIED FILE 1'}, + {path: _('/package/path/folder-2/file-4.js'), contents: 'MODIFIED FILE 4'}, + {path: _('/package/path/folder-3/file-5.js'), contents: 'NEW FILE 5'}, ]); - expect(readFileSync('/package/path/top-level.js.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/package/path/top-level.js.__ivy_ngcc_bak'))) .toEqual('ORIGINAL TOP LEVEL'); - expect(readFileSync('/package/path/folder-1/file-1.js.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/package/path/folder-1/file-1.js.__ivy_ngcc_bak'))) .toEqual('ORIGINAL FILE 1'); - expect(existsSync('/package/path/folder-1/file-2.js.__ivy_ngcc_bak')).toBe(false); - expect(existsSync('/package/path/folder-2/file-3.js.__ivy_ngcc_bak')).toBe(false); - expect(readFileSync('/package/path/folder-2/file-4.js.__ivy_ngcc_bak', 'utf8')) + expect(fs.exists(_('/package/path/folder-1/file-2.js.__ivy_ngcc_bak'))).toBe(false); + expect(fs.exists(_('/package/path/folder-2/file-3.js.__ivy_ngcc_bak'))).toBe(false); + expect(fs.readFile(_('/package/path/folder-2/file-4.js.__ivy_ngcc_bak'))) .toEqual('ORIGINAL FILE 4'); - expect(existsSync('/package/path/folder-3/file-5.js.__ivy_ngcc_bak')).toBe(false); + expect(fs.exists(_('/package/path/folder-3/file-5.js.__ivy_ngcc_bak'))).toBe(false); }); it('should error if the backup file already exists', () => { - const fileWriter = new InPlaceFileWriter(); + const fs = createMockFileSystem(); + const fileWriter = new InPlaceFileWriter(fs); expect( () => fileWriter.writeBundle( {} as EntryPoint, {} as EntryPointBundle, [ - {path: '/package/path/already-backed-up.js', contents: 'MODIFIED BACKED UP'}, + {path: _('/package/path/already-backed-up.js'), contents: 'MODIFIED BACKED UP'}, ])) .toThrowError( 'Tried to overwrite /package/path/already-backed-up.js.__ivy_ngcc_bak with an ngcc back up file, which is disallowed.'); diff --git a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts index 60d44a39b253..051c85f08451 100644 --- a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts +++ b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts @@ -5,22 +5,20 @@ * 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 {existsSync, readFileSync} from 'fs'; -import * as mockFs from 'mock-fs'; - import {AbsoluteFsPath} from '../../../src/ngtsc/path'; +import {FileSystem} from '../../src/file_system/file_system'; import {EntryPoint, EntryPointFormat, EntryPointJsonProperty, getEntryPointInfo} from '../../src/packages/entry_point'; import {EntryPointBundle, makeEntryPointBundle} from '../../src/packages/entry_point_bundle'; import {FileWriter} from '../../src/writing/file_writer'; import {NewEntryPointFileWriter} from '../../src/writing/new_entry_point_file_writer'; +import {MockFileSystem} from '../helpers/mock_file_system'; import {MockLogger} from '../helpers/mock_logger'; import {loadPackageJson} from '../packages/entry_point_spec'; const _ = AbsoluteFsPath.from; function createMockFileSystem() { - mockFs({ + return new MockFileSystem({ '/node_modules/test': { 'package.json': '{"module": "./esm5.js", "es2015": "./es2015/index.js", "typings": "./index.d.ts"}', @@ -73,14 +71,8 @@ function createMockFileSystem() { }); } -function restoreRealFileSystem() { - mockFs.restore(); -} - describe('NewEntryPointFileWriter', () => { - beforeEach(createMockFileSystem); - afterEach(restoreRealFileSystem); - + let fs: FileSystem; let fileWriter: FileWriter; let entryPoint: EntryPoint; let esm5bundle: EntryPointBundle; @@ -88,54 +80,64 @@ describe('NewEntryPointFileWriter', () => { describe('writeBundle() [primary entry-point]', () => { beforeEach(() => { - fileWriter = new NewEntryPointFileWriter(); - entryPoint = - getEntryPointInfo(new MockLogger(), _('/node_modules/test'), _('/node_modules/test')) !; - esm5bundle = makeTestBundle(entryPoint, 'module', 'esm5'); - esm2015bundle = makeTestBundle(entryPoint, 'es2015', 'esm2015'); + fs = createMockFileSystem(); + fileWriter = new NewEntryPointFileWriter(fs); + entryPoint = getEntryPointInfo( + fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test')) !; + esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); + esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); }); it('should write the modified files to a new folder', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/esm5.js', contents: 'export function FooTop() {} // MODIFIED'}, - {path: '/node_modules/test/esm5.js.map', contents: 'MODIFIED MAPPING DATA'}, + { + path: _('/node_modules/test/esm5.js'), + contents: 'export function FooTop() {} // MODIFIED' + }, + {path: _('/node_modules/test/esm5.js.map'), contents: 'MODIFIED MAPPING DATA'}, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/esm5.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/esm5.js'))) .toEqual('export function FooTop() {} // MODIFIED'); - expect(readFileSync('/node_modules/test/esm5.js', 'utf8')) - .toEqual('export function FooTop() {}'); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/esm5.js.map', 'utf8')) + expect(fs.readFile(_('/node_modules/test/esm5.js'))).toEqual('export function FooTop() {}'); + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/esm5.js.map'))) .toEqual('MODIFIED MAPPING DATA'); - expect(readFileSync('/node_modules/test/esm5.js.map', 'utf8')) - .toEqual('ORIGINAL MAPPING DATA'); + expect(fs.readFile(_('/node_modules/test/esm5.js.map'))).toEqual('ORIGINAL MAPPING DATA'); }); it('should also copy unmodified files in the program', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ - {path: '/node_modules/test/es2015/foo.js', contents: 'export class FooTop {} // MODIFIED'}, + { + path: _('/node_modules/test/es2015/foo.js'), + contents: 'export class FooTop {} // MODIFIED' + }, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/es2015/foo.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/es2015/foo.js'))) .toEqual('export class FooTop {} // MODIFIED'); - expect(readFileSync('/node_modules/test/es2015/foo.js', 'utf8')) - .toEqual('export class FooTop {}'); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/es2015/foo.js'))).toEqual('export class FooTop {}'); + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/es2015/index.js'))) .toEqual('export {FooTop} from "./foo";'); - expect(readFileSync('/node_modules/test/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/es2015/index.js'))) .toEqual('export {FooTop} from "./foo";'); }); it('should update the package.json properties', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/esm5.js', contents: 'export function FooTop() {} // MODIFIED'}, + { + path: _('/node_modules/test/esm5.js'), + contents: 'export function FooTop() {} // MODIFIED' + }, ]); - expect(loadPackageJson('/node_modules/test')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '__ivy_ngcc__/esm5.js', })); fileWriter.writeBundle(entryPoint, esm2015bundle, [ - {path: '/node_modules/test/es2015/foo.js', contents: 'export class FooTop {} // MODIFIED'}, + { + path: _('/node_modules/test/es2015/foo.js'), + contents: 'export class FooTop {} // MODIFIED' + }, ]); - expect(loadPackageJson('/node_modules/test')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '__ivy_ngcc__/esm5.js', es2015_ivy_ngcc: '__ivy_ngcc__/es2015/index.js', })); @@ -144,70 +146,80 @@ describe('NewEntryPointFileWriter', () => { it('should overwrite and backup typings files', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/index.d.ts', + path: _('/node_modules/test/index.d.ts'), contents: 'export declare class FooTop {} // MODIFIED' }, - {path: '/node_modules/test/index.d.ts.map', contents: 'MODIFIED MAPPING DATA'}, + {path: _('/node_modules/test/index.d.ts.map'), contents: 'MODIFIED MAPPING DATA'}, ]); - expect(readFileSync('/node_modules/test/index.d.ts', 'utf8')) + expect(fs.readFile(_('/node_modules/test/index.d.ts'))) .toEqual('export declare class FooTop {} // MODIFIED'); - expect(readFileSync('/node_modules/test/index.d.ts.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/node_modules/test/index.d.ts.__ivy_ngcc_bak'))) .toEqual('export declare class FooTop {}'); - expect(existsSync('/node_modules/test/__ivy_ngcc__/index.d.ts')).toBe(false); + expect(fs.exists(_('/node_modules/test/__ivy_ngcc__/index.d.ts'))).toBe(false); - expect(readFileSync('/node_modules/test/index.d.ts.map', 'utf8')) - .toEqual('MODIFIED MAPPING DATA'); - expect(readFileSync('/node_modules/test/index.d.ts.map.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/node_modules/test/index.d.ts.map'))).toEqual('MODIFIED MAPPING DATA'); + expect(fs.readFile(_('/node_modules/test/index.d.ts.map.__ivy_ngcc_bak'))) .toEqual('ORIGINAL MAPPING DATA'); - expect(existsSync('/node_modules/test/__ivy_ngcc__/index.d.ts.map')).toBe(false); + expect(fs.exists(_('/node_modules/test/__ivy_ngcc__/index.d.ts.map'))).toBe(false); }); }); describe('writeBundle() [secondary entry-point]', () => { beforeEach(() => { - fileWriter = new NewEntryPointFileWriter(); - entryPoint = - getEntryPointInfo(new MockLogger(), _('/node_modules/test'), _('/node_modules/test/a')) !; - esm5bundle = makeTestBundle(entryPoint, 'module', 'esm5'); - esm2015bundle = makeTestBundle(entryPoint, 'es2015', 'esm2015'); + fs = createMockFileSystem(); + fileWriter = new NewEntryPointFileWriter(fs); + entryPoint = getEntryPointInfo( + fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/a')) !; + esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); + esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); }); it('should write the modified file to a new folder', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/a/esm5.js', contents: 'export function FooA() {} // MODIFIED'}, + { + path: _('/node_modules/test/a/esm5.js'), + contents: 'export function FooA() {} // MODIFIED' + }, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/a/esm5.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/a/esm5.js'))) .toEqual('export function FooA() {} // MODIFIED'); - expect(readFileSync('/node_modules/test/a/esm5.js', 'utf8')) - .toEqual('export function FooA() {}'); + expect(fs.readFile(_('/node_modules/test/a/esm5.js'))).toEqual('export function FooA() {}'); }); it('should also copy unmodified files in the program', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ - {path: '/node_modules/test/a/es2015/foo.js', contents: 'export class FooA {} // MODIFIED'}, + { + path: _('/node_modules/test/a/es2015/foo.js'), + contents: 'export class FooA {} // MODIFIED' + }, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/a/es2015/foo.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/a/es2015/foo.js'))) .toEqual('export class FooA {} // MODIFIED'); - expect(readFileSync('/node_modules/test/a/es2015/foo.js', 'utf8')) - .toEqual('export class FooA {}'); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/a/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/a/es2015/foo.js'))).toEqual('export class FooA {}'); + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/a/es2015/index.js'))) .toEqual('export {FooA} from "./foo";'); - expect(readFileSync('/node_modules/test/a/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/a/es2015/index.js'))) .toEqual('export {FooA} from "./foo";'); }); it('should update the package.json properties', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/a/esm5.js', contents: 'export function FooA() {} // MODIFIED'}, + { + path: _('/node_modules/test/a/esm5.js'), + contents: 'export function FooA() {} // MODIFIED' + }, ]); - expect(loadPackageJson('/node_modules/test/a')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test/a')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '../__ivy_ngcc__/a/esm5.js', })); fileWriter.writeBundle(entryPoint, esm2015bundle, [ - {path: '/node_modules/test/a/es2015/foo.js', contents: 'export class FooA {} // MODIFIED'}, + { + path: _('/node_modules/test/a/es2015/foo.js'), + contents: 'export class FooA {} // MODIFIED' + }, ]); - expect(loadPackageJson('/node_modules/test/a')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test/a')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '../__ivy_ngcc__/a/esm5.js', es2015_ivy_ngcc: '../__ivy_ngcc__/a/es2015/index.js', })); @@ -216,51 +228,54 @@ describe('NewEntryPointFileWriter', () => { it('should overwrite and backup typings files', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/a/index.d.ts', + path: _('/node_modules/test/a/index.d.ts'), contents: 'export declare class FooA {} // MODIFIED' }, ]); - expect(readFileSync('/node_modules/test/a/index.d.ts', 'utf8')) + expect(fs.readFile(_('/node_modules/test/a/index.d.ts'))) .toEqual('export declare class FooA {} // MODIFIED'); - expect(readFileSync('/node_modules/test/a/index.d.ts.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/node_modules/test/a/index.d.ts.__ivy_ngcc_bak'))) .toEqual('export declare class FooA {}'); - expect(existsSync('/node_modules/test/__ivy_ngcc__/a/index.d.ts')).toBe(false); + expect(fs.exists(_('/node_modules/test/__ivy_ngcc__/a/index.d.ts'))).toBe(false); }); }); describe('writeBundle() [entry-point (with files placed outside entry-point folder)]', () => { beforeEach(() => { - fileWriter = new NewEntryPointFileWriter(); - entryPoint = - getEntryPointInfo(new MockLogger(), _('/node_modules/test'), _('/node_modules/test/b')) !; - esm5bundle = makeTestBundle(entryPoint, 'module', 'esm5'); - esm2015bundle = makeTestBundle(entryPoint, 'es2015', 'esm2015'); + fs = createMockFileSystem(); + fileWriter = new NewEntryPointFileWriter(fs); + entryPoint = getEntryPointInfo( + fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/b')) !; + esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); + esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); }); it('should write the modified file to a new folder', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/lib/esm5.js', contents: 'export function FooB() {} // MODIFIED'}, + { + path: _('/node_modules/test/lib/esm5.js'), + contents: 'export function FooB() {} // MODIFIED' + }, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/lib/esm5.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/lib/esm5.js'))) .toEqual('export function FooB() {} // MODIFIED'); - expect(readFileSync('/node_modules/test/lib/esm5.js', 'utf8')) - .toEqual('export function FooB() {}'); + expect(fs.readFile(_('/node_modules/test/lib/esm5.js'))).toEqual('export function FooB() {}'); }); it('should also copy unmodified files in the program', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/lib/es2015/foo.js', + path: _('/node_modules/test/lib/es2015/foo.js'), contents: 'export class FooB {} // MODIFIED' }, ]); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/lib/es2015/foo.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/lib/es2015/foo.js'))) .toEqual('export class FooB {} // MODIFIED'); - expect(readFileSync('/node_modules/test/lib/es2015/foo.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/lib/es2015/foo.js'))) .toEqual('import {FooA} from "test/a"; import "events"; export class FooB {}'); - expect(readFileSync('/node_modules/test/__ivy_ngcc__/lib/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/__ivy_ngcc__/lib/es2015/index.js'))) .toEqual('export {FooB} from "./foo"; import * from "other";'); - expect(readFileSync('/node_modules/test/lib/es2015/index.js', 'utf8')) + expect(fs.readFile(_('/node_modules/test/lib/es2015/index.js'))) .toEqual('export {FooB} from "./foo"; import * from "other";'); }); @@ -268,39 +283,42 @@ describe('NewEntryPointFileWriter', () => { () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/lib/es2015/foo.js', + path: _('/node_modules/test/lib/es2015/foo.js'), contents: 'export class FooB {} // MODIFIED' }, ]); - expect(existsSync('/node_modules/test/__ivy_ngcc__/a/index.d.ts')).toEqual(false); + expect(fs.exists(_('/node_modules/test/__ivy_ngcc__/a/index.d.ts'))).toEqual(false); }); it('should not copy files outside of the package', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/lib/es2015/foo.js', + path: _('/node_modules/test/lib/es2015/foo.js'), contents: 'export class FooB {} // MODIFIED' }, ]); - expect(existsSync('/node_modules/test/other/index.d.ts')).toEqual(false); - expect(existsSync('/node_modules/test/events/events.js')).toEqual(false); + expect(fs.exists(_('/node_modules/test/other/index.d.ts'))).toEqual(false); + expect(fs.exists(_('/node_modules/test/events/events.js'))).toEqual(false); }); it('should update the package.json properties', () => { fileWriter.writeBundle(entryPoint, esm5bundle, [ - {path: '/node_modules/test/lib/esm5.js', contents: 'export function FooB() {} // MODIFIED'}, + { + path: _('/node_modules/test/lib/esm5.js'), + contents: 'export function FooB() {} // MODIFIED' + }, ]); - expect(loadPackageJson('/node_modules/test/b')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test/b')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '../__ivy_ngcc__/lib/esm5.js', })); fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/lib/es2015/foo.js', + path: _('/node_modules/test/lib/es2015/foo.js'), contents: 'export class FooB {} // MODIFIED' }, ]); - expect(loadPackageJson('/node_modules/test/b')).toEqual(jasmine.objectContaining({ + expect(loadPackageJson(fs, '/node_modules/test/b')).toEqual(jasmine.objectContaining({ module_ivy_ngcc: '../__ivy_ngcc__/lib/esm5.js', es2015_ivy_ngcc: '../__ivy_ngcc__/lib/es2015/index.js', })); @@ -309,23 +327,23 @@ describe('NewEntryPointFileWriter', () => { it('should overwrite and backup typings files', () => { fileWriter.writeBundle(entryPoint, esm2015bundle, [ { - path: '/node_modules/test/typings/index.d.ts', + path: _('/node_modules/test/typings/index.d.ts'), contents: 'export declare class FooB {} // MODIFIED' }, ]); - expect(readFileSync('/node_modules/test/typings/index.d.ts', 'utf8')) + expect(fs.readFile(_('/node_modules/test/typings/index.d.ts'))) .toEqual('export declare class FooB {} // MODIFIED'); - expect(readFileSync('/node_modules/test/typings/index.d.ts.__ivy_ngcc_bak', 'utf8')) + expect(fs.readFile(_('/node_modules/test/typings/index.d.ts.__ivy_ngcc_bak'))) .toEqual('export declare class FooB {}'); - expect(existsSync('/node_modules/test/__ivy_ngcc__/typings/index.d.ts')).toBe(false); + expect(fs.exists(_('/node_modules/test/__ivy_ngcc__/typings/index.d.ts'))).toBe(false); }); }); }); function makeTestBundle( - entryPoint: EntryPoint, formatProperty: EntryPointJsonProperty, + fs: FileSystem, entryPoint: EntryPoint, formatProperty: EntryPointJsonProperty, format: EntryPointFormat): EntryPointBundle { return makeEntryPointBundle( - entryPoint.path, entryPoint.packageJson[formatProperty] !, entryPoint.typings, false, + fs, entryPoint.path, entryPoint.packageJson[formatProperty] !, entryPoint.typings, false, formatProperty, format, true) !; } diff --git a/packages/compiler-cli/src/ngtsc/path/src/types.ts b/packages/compiler-cli/src/ngtsc/path/src/types.ts index 0e2188bf0e88..6561d7e2feee 100644 --- a/packages/compiler-cli/src/ngtsc/path/src/types.ts +++ b/packages/compiler-cli/src/ngtsc/path/src/types.ts @@ -5,7 +5,7 @@ * 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 'path'; import * as ts from 'typescript'; import {isAbsolutePath, normalizeSeparators} from './util'; @@ -62,6 +62,24 @@ export const AbsoluteFsPath = { // ts.SourceFile paths are always absolute. return sf.fileName as AbsoluteFsPath; }, + + /** + * Wrapper around `path.dirname` that returns an absolute path. + */ + dirname: function(file: AbsoluteFsPath): + AbsoluteFsPath { return AbsoluteFsPath.fromUnchecked(path.dirname(file));}, + + /** + * Wrapper around `path.join` that returns an absolute path. + */ + join: function(basePath: AbsoluteFsPath, ...paths: string[]): + AbsoluteFsPath { return AbsoluteFsPath.fromUnchecked(path.posix.join(basePath, ...paths));}, + + /** + * Wrapper around `path.resolve` that returns an absolute paths. + */ + resolve: function(basePath: string, ...paths: string[]): + AbsoluteFsPath { return AbsoluteFsPath.from(path.resolve(basePath, ...paths));}, }; /** @@ -83,4 +101,13 @@ export const PathSegment = { * Convert the path `str` to a `PathSegment`, while assuming that `str` is already normalized. */ fromUnchecked: function(str: string): PathSegment { return str as PathSegment;}, + + /** + * Wrapper around `path.relative` that returns a `PathSegment`. + */ + relative: function(from: AbsoluteFsPath, to: AbsoluteFsPath): + PathSegment { return PathSegment.fromFsPath(path.relative(from, to));}, + + basename: function(filePath: string, extension?: string): + PathSegment { return path.basename(filePath, extension) as PathSegment;} };