/** * @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 {AotCompilerHost, EmitterVisitorContext, ExternalReference, GeneratedFile, ParseSourceSpan, TypeScriptEmitter, collectExternalReferences, syntaxError} from '@angular/compiler'; import * as path from 'path'; import * as ts from 'typescript'; import {TypeCheckHost} from '../diagnostics/translate_diagnostics'; import {METADATA_VERSION, ModuleMetadata} from '../metadata/index'; import {CompilerHost, CompilerOptions, LibrarySummary} from './api'; import {MetadataReaderHost, createMetadataReaderCache, readMetadata} from './metadata_reader'; import {DTS, GENERATED_FILES, isInRootDir, relativeToRootDirs} from './util'; const NODE_MODULES_PACKAGE_NAME = /node_modules\/((\w|-)+|(@(\w|-)+\/(\w|-)+))/; const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/; export function createCompilerHost( {options, tsHost = ts.createCompilerHost(options, true)}: {options: CompilerOptions, tsHost?: ts.CompilerHost}): CompilerHost { return tsHost; } export interface MetadataProvider { getMetadata(sourceFile: ts.SourceFile): ModuleMetadata|undefined; } interface GenSourceFile { externalReferences: Set; sourceFile: ts.SourceFile; emitCtx: EmitterVisitorContext; } export interface CodeGenerator { generateFile(genFileName: string, baseFileName?: string): GeneratedFile; findGeneratedFileNames(fileName: string): string[]; } function assert(condition: T | null | undefined) { if (!condition) { // TODO(chuckjaz): do the right thing } return condition !; } /** * Implements the following hosts based on an api.CompilerHost: * - ts.CompilerHost to be consumed by a ts.Program * - AotCompilerHost for @angular/compiler * - TypeCheckHost for mapping ts errors to ng errors (via translateDiagnostics) */ export class TsCompilerAotCompilerTypeCheckHostAdapter implements ts.CompilerHost, AotCompilerHost, TypeCheckHost { private metadataReaderCache = createMetadataReaderCache(); private flatModuleIndexCache = new Map(); private flatModuleIndexNames = new Set(); private flatModuleIndexRedirectNames = new Set(); private rootDirs: string[]; private moduleResolutionCache: ts.ModuleResolutionCache; private originalSourceFiles = new Map(); private originalFileExistsCache = new Map(); private generatedSourceFiles = new Map(); private generatedCodeFor = new Map(); private emitter = new TypeScriptEmitter(); private metadataReaderHost: MetadataReaderHost; getCancellationToken: () => ts.CancellationToken; getDefaultLibLocation: () => string; trace: (s: string) => void; getDirectories: (path: string) => string[]; directoryExists?: (directoryName: string) => boolean; constructor( private rootFiles: string[], private options: CompilerOptions, private context: CompilerHost, private metadataProvider: MetadataProvider, private codeGenerator: CodeGenerator, private librarySummaries = new Map()) { this.moduleResolutionCache = ts.createModuleResolutionCache( this.context.getCurrentDirectory !(), this.context.getCanonicalFileName.bind(this.context)); const basePath = this.options.basePath !; this.rootDirs = (this.options.rootDirs || [this.options.basePath !]).map(p => path.resolve(basePath, p)); if (context.getDirectories) { this.getDirectories = path => context.getDirectories !(path); } if (context.directoryExists) { this.directoryExists = directoryName => context.directoryExists !(directoryName); } if (context.getCancellationToken) { this.getCancellationToken = () => context.getCancellationToken !(); } if (context.getDefaultLibLocation) { this.getDefaultLibLocation = () => context.getDefaultLibLocation !(); } if (context.trace) { this.trace = s => context.trace !(s); } if (context.fileNameToModuleName) { this.fileNameToModuleName = context.fileNameToModuleName.bind(context); } // Note: don't copy over context.moduleNameToFileName as we first // normalize undefined containingFile to a filled containingFile. if (context.resourceNameToFileName) { this.resourceNameToFileName = context.resourceNameToFileName.bind(context); } if (context.toSummaryFileName) { this.toSummaryFileName = context.toSummaryFileName.bind(context); } if (context.fromSummaryFileName) { this.fromSummaryFileName = context.fromSummaryFileName.bind(context); } this.metadataReaderHost = { cacheMetadata: () => true, getSourceFileMetadata: (filePath) => { const sf = this.getOriginalSourceFile(filePath); return sf ? this.metadataProvider.getMetadata(sf) : undefined; }, fileExists: (filePath) => this.originalFileExists(filePath), readFile: (filePath) => assert(this.context.readFile(filePath)), }; } private resolveModuleName(moduleName: string, containingFile: string): ts.ResolvedModule |undefined { const rm = ts.resolveModuleName( moduleName, containingFile.replace(/\\/g, '/'), this.options, this, this.moduleResolutionCache) .resolvedModule; if (rm && this.isSourceFile(rm.resolvedFileName)) { // Case: generateCodeForLibraries = true and moduleName is // a .d.ts file in a node_modules folder. // Need to set isExternalLibraryImport to false so that generated files for that file // are emitted. rm.isExternalLibraryImport = false; } return rm; } // Note: We implement this method so that TypeScript and Angular share the same // ts.ModuleResolutionCache // and that we can tell ts.Program about our different opinion about // ResolvedModule.isExternalLibraryImport // (see our isSourceFile method). resolveModuleNames(moduleNames: string[], containingFile: string): ts.ResolvedModule[] { // TODO(tbosch): this seems to be a typing error in TypeScript, // as it contains assertions that the result contains the same number of entries // as the given module names. return moduleNames.map( moduleName => this.resolveModuleName(moduleName, containingFile)); } moduleNameToFileName(m: string, containingFile?: string): string|null { if (!containingFile) { if (m.indexOf('.') === 0) { throw new Error('Resolution of relative paths requires a containing file.'); } // Any containing file gives the same result for absolute imports containingFile = this.rootFiles[0]; } if (this.context.moduleNameToFileName) { return this.context.moduleNameToFileName(m, containingFile); } const resolved = this.resolveModuleName(m, containingFile); return resolved ? resolved.resolvedFileName : null; } /** * We want a moduleId that will appear in import statements in the generated code * which will be written to `containingFile`. * * Note that we also generate files for files in node_modules, as libraries * only ship .metadata.json files but not the generated code. * * Logic: * 1. if the importedFile and the containingFile are from the project sources * or from the same node_modules package, use a relative path * 2. if the importedFile is in a node_modules package, * use a path that starts with the package name. * 3. Error if the containingFile is in the node_modules package * and the importedFile is in the project soures, * as that is a violation of the principle that node_modules packages cannot * import project sources. */ fileNameToModuleName(importedFile: string, containingFile: string): string { const originalImportedFile = importedFile; if (this.options.traceResolution) { console.error( 'fileNameToModuleName from containingFile', containingFile, 'to importedFile', importedFile); } // drop extension importedFile = importedFile.replace(EXT, ''); const importedFilePackagName = getPackageName(importedFile); const containingFilePackageName = getPackageName(containingFile); let moduleName: string; if (importedFilePackagName === containingFilePackageName || GENERATED_FILES.test(originalImportedFile)) { const rootedContainingFile = relativeToRootDirs(containingFile, this.rootDirs); const rootedImportedFile = relativeToRootDirs(importedFile, this.rootDirs); if (rootedContainingFile !== containingFile && rootedImportedFile !== importedFile) { // if both files are contained in the `rootDirs`, then strip the rootDirs containingFile = rootedContainingFile; importedFile = rootedImportedFile; } moduleName = dotRelative(path.dirname(containingFile), importedFile); } else if (importedFilePackagName) { moduleName = stripNodeModulesPrefix(importedFile); } else { throw new Error( `Trying to import a source file from a node_modules package: import ${originalImportedFile} from ${containingFile}`); } return moduleName; } resourceNameToFileName(resourceName: string, containingFile: string): string|null { // Note: we convert package paths into relative paths to be compatible with the the // previous implementation of UrlResolver. const firstChar = resourceName[0]; if (firstChar === '/') { resourceName = resourceName.slice(1); } else if (firstChar !== '.') { resourceName = `./${resourceName}`; } const filePathWithNgResource = this.moduleNameToFileName(addNgResourceSuffix(resourceName), containingFile); return filePathWithNgResource ? stripNgResourceSuffix(filePathWithNgResource) : null; } toSummaryFileName(fileName: string, referringSrcFileName: string): string { return this.fileNameToModuleName(fileName, referringSrcFileName); } fromSummaryFileName(fileName: string, referringLibFileName: string): string { const resolved = this.moduleNameToFileName(fileName, referringLibFileName); if (!resolved) { throw new Error(`Could not resolve ${fileName} from ${referringLibFileName}`); } return resolved; } parseSourceSpanOf(fileName: string, line: number, character: number): ParseSourceSpan|null { const data = this.generatedSourceFiles.get(fileName); if (data && data.emitCtx) { return data.emitCtx.spanOf(line, character); } return null; } private getOriginalSourceFile( filePath: string, languageVersion?: ts.ScriptTarget, onError?: ((message: string) => void)|undefined): ts.SourceFile|null { // Note: we need the explicit check via `has` as we also cache results // that were null / undefined. if (this.originalSourceFiles.has(filePath)) { return this.originalSourceFiles.get(filePath) !; } if (!languageVersion) { languageVersion = this.options.target || ts.ScriptTarget.Latest; } // Note: This can also return undefined, // as the TS typings are not correct! const sf = this.context.getSourceFile(filePath, languageVersion, onError) || null; this.originalSourceFiles.set(filePath, sf); return sf; } updateGeneratedFile(genFile: GeneratedFile): ts.SourceFile { if (!genFile.stmts) { throw new Error( `Invalid Argument: Expected a GenerateFile with statements. ${genFile.genFileUrl}`); } const oldGenFile = this.generatedSourceFiles.get(genFile.genFileUrl); if (!oldGenFile) { throw new Error(`Illegal State: previous GeneratedFile not found for ${genFile.genFileUrl}.`); } const newRefs = genFileExternalReferences(genFile); const oldRefs = oldGenFile.externalReferences; let refsAreEqual = oldRefs.size === newRefs.size; if (refsAreEqual) { newRefs.forEach(r => refsAreEqual = refsAreEqual && oldRefs.has(r)); } if (!refsAreEqual) { throw new Error( `Illegal State: external references changed in ${genFile.genFileUrl}.\nOld: ${Array.from(oldRefs)}.\nNew: ${Array.from(newRefs)}`); } return this.addGeneratedFile(genFile, newRefs); } private addGeneratedFile(genFile: GeneratedFile, externalReferences: Set): ts.SourceFile { if (!genFile.stmts) { throw new Error( `Invalid Argument: Expected a GenerateFile with statements. ${genFile.genFileUrl}`); } const {sourceText, context} = this.emitter.emitStatementsAndContext( genFile.genFileUrl, genFile.stmts, /* preamble */ '', /* emitSourceMaps */ false); const sf = ts.createSourceFile( genFile.genFileUrl, sourceText, this.options.target || ts.ScriptTarget.Latest); if ((this.options.module === ts.ModuleKind.AMD || this.options.module === ts.ModuleKind.UMD) && this.context.amdModuleName) { const moduleName = this.context.amdModuleName(sf); if (moduleName) sf.moduleName = moduleName; } this.generatedSourceFiles.set(genFile.genFileUrl, { sourceFile: sf, emitCtx: context, externalReferences, }); return sf; } shouldGenerateFile(fileName: string): {generate: boolean, baseFileName?: string} { // TODO(tbosch): allow generating files that are not in the rootDir // See https://github.com/angular/angular/issues/19337 if (!isInRootDir(fileName, this.options)) { return {generate: false}; } const genMatch = GENERATED_FILES.exec(fileName); if (!genMatch) { return {generate: false}; } const [, base, genSuffix, suffix] = genMatch; if (suffix !== 'ts') { return {generate: false}; } let baseFileName: string|undefined; if (genSuffix.indexOf('ngstyle') >= 0) { // Note: ngstyle files have names like `afile.css.ngstyle.ts` if (!this.originalFileExists(base)) { return {generate: false}; } } else { // Note: on-the-fly generated files always have a `.ts` suffix, // but the file from which we generated it can be a `.ts`/ `.d.ts` // (see options.generateCodeForLibraries). baseFileName = [`${base}.ts`, `${base}.d.ts`].find( baseFileName => this.isSourceFile(baseFileName) && this.originalFileExists(baseFileName)); if (!baseFileName) { return {generate: false}; } } return {generate: true, baseFileName}; } shouldGenerateFilesFor(fileName: string) { // TODO(tbosch): allow generating files that are not in the rootDir // See https://github.com/angular/angular/issues/19337 return !GENERATED_FILES.test(fileName) && this.isSourceFile(fileName) && isInRootDir(fileName, this.options); } getSourceFile( fileName: string, languageVersion: ts.ScriptTarget, onError?: ((message: string) => void)|undefined): ts.SourceFile { // Note: Don't exit early in this method to make sure // we always have up to date references on the file! let genFileNames: string[] = []; let sf = this.getGeneratedFile(fileName); if (!sf) { const summary = this.librarySummaries.get(fileName); if (summary) { if (!summary.sourceFile) { summary.sourceFile = ts.createSourceFile( fileName, summary.text, this.options.target || ts.ScriptTarget.Latest); } sf = summary.sourceFile; genFileNames = []; } } if (!sf) { sf = this.getOriginalSourceFile(fileName); const cachedGenFiles = this.generatedCodeFor.get(fileName); if (cachedGenFiles) { genFileNames = cachedGenFiles; } else { if (!this.options.noResolve && this.shouldGenerateFilesFor(fileName)) { genFileNames = this.codeGenerator.findGeneratedFileNames(fileName).filter( fileName => this.shouldGenerateFile(fileName).generate); } this.generatedCodeFor.set(fileName, genFileNames); } } if (sf) { addReferencesToSourceFile(sf, genFileNames); } // TODO(tbosch): TypeScript's typings for getSourceFile are incorrect, // as it can very well return undefined. return sf !; } private getGeneratedFile(fileName: string): ts.SourceFile|null { const genSrcFile = this.generatedSourceFiles.get(fileName); if (genSrcFile) { return genSrcFile.sourceFile; } const {generate, baseFileName} = this.shouldGenerateFile(fileName); if (generate) { const genFile = this.codeGenerator.generateFile(fileName, baseFileName); return this.addGeneratedFile(genFile, genFileExternalReferences(genFile)); } return null; } private originalFileExists(fileName: string): boolean { let fileExists = this.originalFileExistsCache.get(fileName); if (fileExists == null) { fileExists = this.context.fileExists(fileName); this.originalFileExistsCache.set(fileName, fileExists); } return fileExists; } fileExists(fileName: string): boolean { fileName = stripNgResourceSuffix(fileName); if (this.librarySummaries.has(fileName) || this.generatedSourceFiles.has(fileName)) { return true; } if (this.shouldGenerateFile(fileName).generate) { return true; } return this.originalFileExists(fileName); } loadSummary(filePath: string): string|null { const summary = this.librarySummaries.get(filePath); if (summary) { return summary.text; } if (this.originalFileExists(filePath)) { return assert(this.context.readFile(filePath)); } return null; } isSourceFile(filePath: string): boolean { // Don't generate any files nor typecheck them // if skipTemplateCodegen is set and fullTemplateTypeCheck is not yet set, // for backwards compatibility. if (this.options.skipTemplateCodegen && !this.options.fullTemplateTypeCheck) { return false; } // If we have a summary from a previous compilation, // treat the file never as a source file. if (this.librarySummaries.has(filePath)) { return false; } if (GENERATED_FILES.test(filePath)) { return false; } if (this.options.generateCodeForLibraries === false && DTS.test(filePath)) { return false; } if (DTS.test(filePath)) { // Check for a bundle index. if (this.hasBundleIndex(filePath)) { const normalFilePath = path.normalize(filePath); return this.flatModuleIndexNames.has(normalFilePath) || this.flatModuleIndexRedirectNames.has(normalFilePath); } } return true; } readFile(fileName: string) { const summary = this.librarySummaries.get(fileName); if (summary) { return summary.text; } return this.context.readFile(fileName); } getMetadataFor(filePath: string): ModuleMetadata[]|undefined { return readMetadata(filePath, this.metadataReaderHost, this.metadataReaderCache); } loadResource(filePath: string): Promise|string { if (this.context.readResource) return this.context.readResource(filePath); if (!this.originalFileExists(filePath)) { throw syntaxError(`Error: Resource file not found: ${filePath}`); } return assert(this.context.readFile(filePath)); } private hasBundleIndex(filePath: string): boolean { const checkBundleIndex = (directory: string): boolean => { let result = this.flatModuleIndexCache.get(directory); if (result == null) { if (path.basename(directory) == 'node_module') { // Don't look outside the node_modules this package is installed in. result = false; } else { // A bundle index exists if the typings .d.ts file has a metadata.json that has an // importAs. try { const packageFile = path.join(directory, 'package.json'); if (this.originalFileExists(packageFile)) { // Once we see a package.json file, assume false until it we find the bundle index. result = false; const packageContent: any = JSON.parse(assert(this.context.readFile(packageFile))); if (packageContent.typings) { const typings = path.normalize(path.join(directory, packageContent.typings)); if (DTS.test(typings)) { const metadataFile = typings.replace(DTS, '.metadata.json'); if (this.originalFileExists(metadataFile)) { const metadata = JSON.parse(assert(this.context.readFile(metadataFile))); if (metadata.flatModuleIndexRedirect) { this.flatModuleIndexRedirectNames.add(typings); // Note: don't set result = true, // as this would mark this folder // as having a bundleIndex too early without // filling the bundleIndexNames. } else if (metadata.importAs) { this.flatModuleIndexNames.add(typings); result = true; } } } } } else { const parent = path.dirname(directory); if (parent != directory) { // Try the parent directory. result = checkBundleIndex(parent); } else { result = false; } } } catch (e) { // If we encounter any errors assume we this isn't a bundle index. result = false; } } this.flatModuleIndexCache.set(directory, result); } return result; }; return checkBundleIndex(path.dirname(filePath)); } getDefaultLibFileName = (options: ts.CompilerOptions) => this.context.getDefaultLibFileName(options) getCurrentDirectory = () => this.context.getCurrentDirectory(); getCanonicalFileName = (fileName: string) => this.context.getCanonicalFileName(fileName); useCaseSensitiveFileNames = () => this.context.useCaseSensitiveFileNames(); getNewLine = () => this.context.getNewLine(); // Make sure we do not `host.realpath()` from TS as we do not want to resolve symlinks. // https://github.com/Microsoft/TypeScript/issues/9552 realPath = (p: string) => p; writeFile = this.context.writeFile.bind(this.context); } function genFileExternalReferences(genFile: GeneratedFile): Set { return new Set(collectExternalReferences(genFile.stmts !).map(er => er.moduleName !)); } function addReferencesToSourceFile(sf: ts.SourceFile, genFileNames: string[]) { // Note: as we modify ts.SourceFiles we need to keep the original // value for `referencedFiles` around in cache the original host is caching ts.SourceFiles. // Note: cloning the ts.SourceFile is expensive as the nodes in have parent pointers, // i.e. we would also need to clone and adjust all nodes. let originalReferencedFiles: ts.FileReference[]|undefined = (sf as any).originalReferencedFiles; if (!originalReferencedFiles) { originalReferencedFiles = sf.referencedFiles; (sf as any).originalReferencedFiles = originalReferencedFiles; } const newReferencedFiles = [...originalReferencedFiles]; genFileNames.forEach(gf => newReferencedFiles.push({fileName: gf, pos: 0, end: 0})); sf.referencedFiles = newReferencedFiles; } export function getOriginalReferences(sourceFile: ts.SourceFile): ts.FileReference[]|undefined { return sourceFile && (sourceFile as any).originalReferencedFiles; } function dotRelative(from: string, to: string): string { const rPath: string = path.relative(from, to).replace(/\\/g, '/'); return rPath.startsWith('.') ? rPath : './' + rPath; } /** * Moves the path into `genDir` folder while preserving the `node_modules` directory. */ function getPackageName(filePath: string): string|null { const match = NODE_MODULES_PACKAGE_NAME.exec(filePath); return match ? match[1] : null; } function stripNodeModulesPrefix(filePath: string): string { return filePath.replace(/.*node_modules\//, ''); } function getNodeModulesPrefix(filePath: string): string|null { const match = /.*node_modules\//.exec(filePath); return match ? match[1] : null; } function stripNgResourceSuffix(fileName: string): string { return fileName.replace(/\.\$ngresource\$.*/, ''); } function addNgResourceSuffix(fileName: string): string { return `${fileName}.$ngresource$`; }