blob: 2b7113b153dc18f04deb843736f8c04899fbf37f [file] [log] [blame]
import * as fs from 'fs';
import * as path from 'path';
import * as tsickle from 'tsickle';
import * as ts from 'typescript';
import {FileLoader} from './cache';
import * as perfTrace from './perf_trace';
import {BazelOptions} from './tsconfig';
import {DEBUG, debug} from './worker';
export type ModuleResolver =
(moduleName: string, containingFile: string,
compilerOptions: ts.CompilerOptions, host: ts.ModuleResolutionHost) =>
ts.ResolvedModuleWithFailedLookupLocations;
/**
* Narrows down the type of some properties from non-optional to required, so
* that we do not need to check presence before each access.
*/
export interface BazelTsOptions extends ts.CompilerOptions {
rootDirs: string[];
rootDir: string;
outDir: string;
typeRoots: string[];
}
export function narrowTsOptions(options: ts.CompilerOptions): BazelTsOptions {
if (!options.rootDirs) {
throw new Error(`compilerOptions.rootDirs should be set by tsconfig.bzl`);
}
if (!options.rootDir) {
throw new Error(`compilerOptions.rootDir should be set by tsconfig.bzl`);
}
if (!options.outDir) {
throw new Error(`compilerOptions.outDir should be set by tsconfig.bzl`);
}
return options as BazelTsOptions;
}
function validateBazelOptions(bazelOpts: BazelOptions) {
if (!bazelOpts.isJsTranspilation) return;
if (bazelOpts.compilationTargetSrc &&
bazelOpts.compilationTargetSrc.length > 1) {
throw new Error(
'In JS transpilation mode, only one file can appear in ' +
'bazelOptions.compilationTargetSrc.');
}
if (!bazelOpts.transpiledJsOutputFileName &&
!bazelOpts.transpiledJsOutputDirectory) {
throw new Error(
'In JS transpilation mode, either transpiledJsOutputFileName or ' +
'transpiledJsOutputDirectory must be specified in tsconfig.');
}
if (bazelOpts.transpiledJsOutputFileName &&
bazelOpts.transpiledJsOutputDirectory) {
throw new Error(
'In JS transpilation mode, cannot set both ' +
'transpiledJsOutputFileName and transpiledJsOutputDirectory.');
}
}
const SOURCE_EXT = /((\.d)?\.tsx?|\.js)$/;
/**
* CompilerHost that knows how to cache parsed files to improve compile times.
*/
export class CompilerHost implements ts.CompilerHost, tsickle.TsickleHost {
/**
* Lookup table to answer file stat's without looking on disk.
*/
private knownFiles = new Set<string>();
/**
* rootDirs relative to the rootDir, eg "bazel-out/local-fastbuild/bin"
*/
private relativeRoots: string[];
getCancelationToken?: () => ts.CancellationToken;
directoryExists?: (dir: string) => boolean;
googmodule: boolean;
es5Mode: boolean;
prelude: string;
untyped: boolean;
typeBlackListPaths: Set<string>;
transformDecorators: boolean;
transformTypesToClosure: boolean;
addDtsClutzAliases: boolean;
isJsTranspilation: boolean;
provideExternalModuleDtsNamespace: boolean;
options: BazelTsOptions;
moduleResolutionHost: ts.ModuleResolutionHost = this;
// TODO(evanm): delete this once tsickle is updated.
host: ts.ModuleResolutionHost = this;
private allowActionInputReads = true;
constructor(
public inputFiles: string[], options: ts.CompilerOptions,
readonly bazelOpts: BazelOptions, private delegate: ts.CompilerHost,
private fileLoader: FileLoader,
private moduleResolver: ModuleResolver = ts.resolveModuleName) {
this.options = narrowTsOptions(options);
this.relativeRoots =
this.options.rootDirs.map(r => path.relative(this.options.rootDir, r));
inputFiles.forEach((f) => {
this.knownFiles.add(f);
});
// getCancelationToken is an optional method on the delegate. If we
// unconditionally implement the method, we will be forced to return null,
// in the absense of the delegate method. That won't match the return type.
// Instead, we optionally set a function to a field with the same name.
if (delegate && delegate.getCancellationToken) {
this.getCancelationToken = delegate.getCancellationToken.bind(delegate);
}
// Override directoryExists so that TypeScript can automatically
// include global typings from node_modules/@types
// see getAutomaticTypeDirectiveNames in
// TypeScript:src/compiler/moduleNameResolver
if (this.allowActionInputReads && delegate && delegate.directoryExists) {
this.directoryExists = delegate.directoryExists.bind(delegate);
}
validateBazelOptions(bazelOpts);
this.googmodule = bazelOpts.googmodule;
this.es5Mode = bazelOpts.es5Mode;
this.prelude = bazelOpts.prelude;
this.untyped = bazelOpts.untyped;
this.typeBlackListPaths = new Set(bazelOpts.typeBlackListPaths);
this.transformDecorators = bazelOpts.tsickle;
this.transformTypesToClosure = bazelOpts.tsickle;
this.addDtsClutzAliases = bazelOpts.addDtsClutzAliases;
this.isJsTranspilation = Boolean(bazelOpts.isJsTranspilation);
this.provideExternalModuleDtsNamespace = !bazelOpts.hasImplementation;
}
/**
* For the given potentially absolute input file path (typically .ts), returns
* the relative output path. For example, for
* /path/to/root/blaze-out/k8-fastbuild/genfiles/my/file.ts, will return
* my/file.js or my/file.mjs (depending on ES5 mode).
*/
relativeOutputPath(fileName: string) {
let result = this.rootDirsRelative(fileName);
result = result.replace(/(\.d)?\.[jt]sx?$/, '');
if (!this.bazelOpts.es5Mode) result += '.closure';
return result + '.js';
}
/**
* Workaround https://github.com/Microsoft/TypeScript/issues/8245
* We use the `rootDirs` property both for module resolution,
* and *also* to flatten the structure of the output directory
* (as `rootDir` would do for a single root).
* To do this, look for the pattern outDir/relativeRoots[i]/path/to/file
* or relativeRoots[i]/path/to/file
* and replace that with path/to/file
*/
flattenOutDir(fileName: string): string {
let result = fileName;
// outDir/relativeRoots[i]/path/to/file -> relativeRoots[i]/path/to/file
if (fileName.startsWith(this.options.rootDir)) {
result = path.relative(this.options.outDir, fileName);
}
for (const dir of this.relativeRoots) {
// relativeRoots[i]/path/to/file -> path/to/file
const rel = path.relative(dir, result);
if (!rel.startsWith('..')) {
result = rel;
// relativeRoots is sorted longest first so we can short-circuit
// after the first match
break;
}
}
return result;
}
/** Avoid using tsickle on files that aren't in srcs[] */
shouldSkipTsickleProcessing(fileName: string): boolean {
return this.bazelOpts.isJsTranspilation ||
this.bazelOpts.compilationTargetSrc.indexOf(fileName) === -1;
}
/** Whether the file is expected to be imported using a named module */
shouldNameModule(fileName: string): boolean {
return this.bazelOpts.compilationTargetSrc.indexOf(fileName) !== -1;
}
/** Allows suppressing warnings for specific known libraries */
shouldIgnoreWarningsForPath(filePath: string): boolean {
return this.bazelOpts.ignoreWarningPaths.some(
p => !!filePath.match(new RegExp(p)));
}
/**
* fileNameToModuleId gives the module ID for an input source file name.
* @param fileName an input source file name, e.g.
* /root/dir/bazel-out/host/bin/my/file.ts.
* @return the canonical path of a file within blaze, without /genfiles/ or
* /bin/ path parts, excluding a file extension. For example, "my/file".
*/
fileNameToModuleId(fileName: string): string {
return this.relativeOutputPath(
fileName.substring(0, fileName.lastIndexOf('.')));
}
/**
* TypeScript SourceFile's have a path with the rootDirs[i] still present, eg.
* /build/work/bazel-out/local-fastbuild/bin/path/to/file
* @return the path without any rootDirs, eg. path/to/file
*/
private rootDirsRelative(fileName: string): string {
for (const root of this.options.rootDirs) {
if (fileName.startsWith(root)) {
// rootDirs are sorted longest-first, so short-circuit the iteration
// see tsconfig.ts.
return path.posix.relative(root, fileName);
}
}
return fileName;
}
/**
* Massages file names into valid goog.module names:
* - resolves relative paths to the given context
* - resolves non-relative paths which takes module_root into account
* - replaces '/' with '.' in the '<workspace>' namespace
* - replace first char if non-alpha
* - replace subsequent non-alpha numeric chars
*/
pathToModuleName(context: string, importPath: string): string {
// tsickle hands us an output path, we need to map it back to a source
// path in order to do module resolution with it.
// outDir/relativeRoots[i]/path/to/file ->
// rootDir/relativeRoots[i]/path/to/file
if (context.startsWith(this.options.outDir)) {
context = path.join(
this.options.rootDir, path.relative(this.options.outDir, context));
}
// Try to get the resolved path name from TS compiler host which can
// handle resolution for libraries with module_root like rxjs and @angular.
let resolvedPath: string|null = null;
const resolved =
this.moduleResolver(importPath, context, this.options, this);
if (resolved && resolved.resolvedModule &&
resolved.resolvedModule.resolvedFileName) {
resolvedPath = resolved.resolvedModule.resolvedFileName;
// /build/work/bazel-out/local-fastbuild/bin/path/to/file ->
// path/to/file
resolvedPath = this.rootDirsRelative(resolvedPath);
} else {
// importPath can be an absolute file path in google3.
// Try to trim it as a path relative to bin and genfiles, and if so,
// handle its file extension in the block below and prepend the workspace
// name.
const trimmed = this.rootDirsRelative(importPath);
if (trimmed !== importPath) {
resolvedPath = trimmed;
}
}
if (resolvedPath) {
// Strip file extensions.
importPath = resolvedPath.replace(SOURCE_EXT, '');
// Make sure all module names include the workspace name.
if (importPath.indexOf(this.bazelOpts.workspaceName) !== 0) {
importPath = path.posix.join(this.bazelOpts.workspaceName, importPath);
}
}
// Remove the __{LOCALE} from the module name.
if (this.bazelOpts.locale) {
const suffix = '__' + this.bazelOpts.locale.toLowerCase();
if (importPath.toLowerCase().endsWith(suffix)) {
importPath = importPath.substring(0, importPath.length - suffix.length);
}
}
// Replace characters not supported by goog.module and '.' with
// '$<Hex char code>' so that the original module name can be re-obtained
// without any loss.
// See goog.VALID_MODULE_RE_ in Closure's base.js for characters supported
// by google.module.
const escape = (c: string) => {
return '$' + c.charCodeAt(0).toString(16);
};
const moduleName = importPath.replace(/^[0-9]|[^a-zA-Z_0-9_/]/g, escape)
.replace(/\//g, '.');
return moduleName;
}
/**
* Converts file path into a valid AMD module name.
*
* An AMD module can have an arbitrary name, so that it is require'd by name
* rather than by path. See http://requirejs.org/docs/whyamd.html#namedmodules
*
* "However, tools that combine multiple modules together for performance need
* a way to give names to each module in the optimized file. For that, AMD
* allows a string as the first argument to define()"
*/
amdModuleName(sf: ts.SourceFile): string|undefined {
if (!this.shouldNameModule(sf.fileName)) return undefined;
// /build/work/bazel-out/local-fastbuild/bin/path/to/file.ts
// -> path/to/file
let fileName = this.rootDirsRelative(sf.fileName).replace(SOURCE_EXT, '');
let workspace = this.bazelOpts.workspaceName;
// Workaround https://github.com/bazelbuild/bazel/issues/1262
//
// When the file comes from an external bazel repository,
// and TypeScript resolves runfiles symlinks, then the path will look like
// output_base/execroot/local_repo/external/another_repo/foo/bar
// We want to name such a module "another_repo/foo/bar" just as it would be
// named by code in that repository.
// As a workaround, check for the /external/ path segment, and fix up the
// workspace name to be the name of the external repository.
if (fileName.startsWith('external/')) {
const parts = fileName.split('/');
workspace = parts[1];
fileName = parts.slice(2).join('/');
}
if (this.bazelOpts.moduleName) {
const relativeFileName = path.posix.relative(this.bazelOpts.package, fileName);
// check that the fileName was actually underneath the package directory
if (!relativeFileName.startsWith('..')) {
if (this.bazelOpts.moduleRoot) {
const root = this.bazelOpts.moduleRoot.replace(SOURCE_EXT, '');
if (root === relativeFileName ||
path.posix.join(root, 'index') === relativeFileName) {
return this.bazelOpts.moduleName;
}
}
// Support the common case of commonjs convention that index is the
// default module in a directory.
// This makes our module naming scheme more conventional and lets users
// refer to modules with the natural name they're used to.
if (relativeFileName === 'index') {
return this.bazelOpts.moduleName;
}
return path.posix.join(this.bazelOpts.moduleName, relativeFileName);
}
}
if (fileName.startsWith('node_modules/')) {
return fileName.substring('node_modules/'.length);
}
// path/to/file ->
// myWorkspace/path/to/file
return path.posix.join(workspace, fileName);
}
/**
* Resolves the typings file from a package at the specified path. Helper
* function to `resolveTypeReferenceDirectives`.
*/
private resolveTypingFromDirectory(typePath: string, primary: boolean): ts.ResolvedTypeReferenceDirective | undefined {
// Looks for the `typings` attribute in a package.json file
// if it exists
const pkgFile = path.posix.join(typePath, 'package.json');
if (this.fileExists(pkgFile)) {
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'UTF-8'));
let typings = pkg['typings'];
if (typings) {
if (typings === '.' || typings === './') {
typings = 'index.d.ts';
}
const maybe = path.posix.join(typePath, typings);
if (this.fileExists(maybe)) {
return { primary, resolvedFileName: maybe };
}
}
}
// Look for an index.d.ts file in the path
const maybe = path.posix.join(typePath, 'index.d.ts');
if (this.fileExists(maybe)) {
return { primary, resolvedFileName: maybe };
}
return undefined;
}
/**
* Override the default typescript resolveTypeReferenceDirectives function.
* Resolves /// <reference types="x" /> directives under bazel. The default
* typescript secondary search behavior needs to be overridden to support
* looking under `bazelOpts.nodeModulesPrefix`
*/
resolveTypeReferenceDirectives(names: string[], containingFile: string): ts.ResolvedTypeReferenceDirective[] {
if (!this.allowActionInputReads) return [];
const result: ts.ResolvedTypeReferenceDirective[] = [];
names.forEach(name => {
let resolved: ts.ResolvedTypeReferenceDirective | undefined;
// primary search
this.options.typeRoots.forEach(typeRoot => {
if (!resolved) {
resolved = this.resolveTypingFromDirectory(path.posix.join(typeRoot, name), true);
}
});
// secondary search
if (!resolved) {
resolved = this.resolveTypingFromDirectory(path.posix.join(this.bazelOpts.nodeModulesPrefix, name), false);
}
// Types not resolved should be silently ignored. Leave it to Typescript
// to either error out with "TS2688: Cannot find type definition file for
// 'foo'" or for the build to fail due to a missing type that is used.
if (!resolved) {
if (DEBUG) {
debug(`Failed to resolve type reference directive '${name}'`);
}
return;
}
// In typescript 2.x the return type for this function
// is `(ts.ResolvedTypeReferenceDirective | undefined)[]` thus we actually
// do allow returning `undefined` in the array but the function is typed
// `(ts.ResolvedTypeReferenceDirective)[]` to compile with both typescript
// 2.x and 3.0/3.1 without error. Typescript 3.0/3.1 do handle the `undefined`
// values in the array correctly despite the return signature.
// It looks like the return type change was a mistake because
// it was changed back to include `| undefined` recently:
// https://github.com/Microsoft/TypeScript/pull/28059.
result.push(resolved as ts.ResolvedTypeReferenceDirective);
});
return result;
}
/** Loads a source file from disk (or the cache). */
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: (message: string) => void) {
return perfTrace.wrap(`getSourceFile ${fileName}`, () => {
const sf = this.fileLoader.loadFile(fileName, fileName, languageVersion);
if (!/\.d\.tsx?$/.test(fileName) &&
(this.options.module === ts.ModuleKind.AMD ||
this.options.module === ts.ModuleKind.UMD)) {
const moduleName = this.amdModuleName(sf);
if (sf.moduleName === moduleName || !moduleName) return sf;
if (sf.moduleName) {
throw new Error(
`ERROR: ${sf.fileName} ` +
`contains a module name declaration ${sf.moduleName} ` +
`which would be overwritten with ${moduleName} ` +
`by Bazel's TypeScript compiler.`);
}
// Setting the moduleName is equivalent to the original source having a
// ///<amd-module name="some/name"/> directive
sf.moduleName = moduleName;
}
return sf;
});
}
writeFile(
fileName: string, content: string, writeByteOrderMark: boolean,
onError: ((message: string) => void)|undefined,
sourceFiles: ReadonlyArray<ts.SourceFile>|undefined): void {
perfTrace.wrap(
`writeFile ${fileName}`,
() => this.writeFileImpl(
fileName, content, writeByteOrderMark, onError, sourceFiles));
}
writeFileImpl(
fileName: string, content: string, writeByteOrderMark: boolean,
onError: ((message: string) => void)|undefined,
sourceFiles: ReadonlyArray<ts.SourceFile>|undefined): void {
// Workaround https://github.com/Microsoft/TypeScript/issues/18648
// This bug is fixed in TS 2.9
const version = ts.versionMajorMinor;
const [major, minor] = version.split('.').map(s => Number(s));
const workaroundNeeded = major <= 2 && minor <= 8;
if (workaroundNeeded &&
(this.options.module === ts.ModuleKind.AMD ||
this.options.module === ts.ModuleKind.UMD) &&
fileName.endsWith('.d.ts') && sourceFiles && sourceFiles.length > 0 &&
sourceFiles[0].moduleName) {
content =
`/// <amd-module name="${sourceFiles[0].moduleName}" />\n${content}`;
}
fileName = this.flattenOutDir(fileName);
if (this.bazelOpts.isJsTranspilation) {
if (this.bazelOpts.transpiledJsOutputFileName) {
fileName = this.bazelOpts.transpiledJsOutputFileName!;
} else {
// Strip the input directory path off of fileName to get the logical
// path within the input directory.
fileName =
path.relative(this.bazelOpts.transpiledJsInputDirectory!, fileName);
// Then prepend the output directory name.
fileName =
path.join(this.bazelOpts.transpiledJsOutputDirectory!, fileName);
}
} else if (!this.bazelOpts.es5Mode) {
// Write ES6 transpiled files to *.mjs.
if (this.bazelOpts.locale) {
// i18n paths are required to end with __locale.js so we put
// the .closure segment before the __locale
fileName = fileName.replace(/(__[^\.]+)?\.js$/, '.closure$1.js');
} else {
fileName = fileName.replace(/\.js$/, '.mjs');
}
}
// Prepend the output directory.
fileName = path.join(this.options.outDir, fileName);
// Our file cache is based on mtime - so avoid writing files if they
// did not change.
if (!fs.existsSync(fileName) ||
fs.readFileSync(fileName, 'utf-8') !== content) {
this.delegate.writeFile(
fileName, content, writeByteOrderMark, onError, sourceFiles);
}
}
/**
* Performance optimization: don't try to stat files we weren't explicitly
* given as inputs.
* This also allows us to disable Bazel sandboxing, without accidentally
* reading .ts inputs when .d.ts inputs are intended.
* Note that in worker mode, the file cache will also guard against arbitrary
* file reads.
*/
fileExists(filePath: string): boolean {
// Under Bazel, users do not declare deps[] on their node_modules.
// This means that we do not list all the needed .d.ts files in the files[]
// section of tsconfig.json, and that is what populates the knownFiles set.
// In addition, the node module resolver may need to read package.json files
// and these are not permitted in the files[] section.
// So we permit reading node_modules/* from action inputs, even though this
// can include data[] dependencies and is broader than we would like.
// This should only be enabled under Bazel, not Blaze.
if (this.allowActionInputReads && filePath.indexOf('/node_modules/') >= 0) {
const result = this.fileLoader.fileExists(filePath);
if (DEBUG && !result && this.delegate.fileExists(filePath)) {
debug("Path exists, but is not registered in the cache", filePath);
Object.keys((this.fileLoader as any).cache.lastDigests).forEach(k => {
if (k.endsWith(path.basename(filePath))) {
debug(" Maybe you meant to load from", k);
}
});
}
return result;
}
return this.knownFiles.has(filePath);
}
getDefaultLibLocation(): string {
// Since we override getDefaultLibFileName below, we must also provide the
// directory containing the file.
// Otherwise TypeScript looks in C:\lib.xxx.d.ts for the default lib.
return path.dirname(
this.getDefaultLibFileName({target: ts.ScriptTarget.ES5}));
}
getDefaultLibFileName(options: ts.CompilerOptions): string {
if (this.bazelOpts.nodeModulesPrefix) {
return path.join(
this.bazelOpts.nodeModulesPrefix, 'typescript/lib',
ts.getDefaultLibFileName({target: ts.ScriptTarget.ES5}));
}
return this.delegate.getDefaultLibFileName(options);
}
realpath(s: string): string {
// tsc-wrapped relies on string matching of file paths for things like the
// file cache and for strict deps checking.
// TypeScript will try to resolve symlinks during module resolution which
// makes our checks fail: the path we resolved as an input isn't the same
// one the module resolver will look for.
// See https://github.com/Microsoft/TypeScript/pull/12020
// So we simply turn off symlink resolution.
return s;
}
// Delegate everything else to the original compiler host.
getCanonicalFileName(path: string) {
return this.delegate.getCanonicalFileName(path);
}
getCurrentDirectory(): string {
return this.delegate.getCurrentDirectory();
}
useCaseSensitiveFileNames(): boolean {
return this.delegate.useCaseSensitiveFileNames();
}
getNewLine(): string {
return this.delegate.getNewLine();
}
getDirectories(path: string) {
return this.delegate.getDirectories ? this.delegate.getDirectories(path) :
[];
}
readFile(fileName: string): string|undefined {
return this.delegate.readFile(fileName);
}
trace(s: string): void {
console.error(s);
}
}