@bazel/typescript
Version:
TypeScript rules for Bazel
572 lines (571 loc) • 26.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require("fs");
const path = require("path");
const ts = require("typescript");
const runner_1 = require("../tsetse/runner");
const cache_1 = require("./cache");
const compiler_host_1 = require("./compiler_host");
const bazelDiagnostics = require("./diagnostics");
const manifest_1 = require("./manifest");
const perfTrace = require("./perf_trace");
const strict_deps_1 = require("./strict_deps");
const tsconfig_1 = require("./tsconfig");
const worker_1 = require("./worker");
/**
* Top-level entry point for tsc_wrapped.
*/
function main(args) {
if (worker_1.runAsWorker(args)) {
worker_1.log('Starting TypeScript compiler persistent worker...');
worker_1.runWorkerLoop(runOneBuild);
// Note: intentionally don't process.exit() here, because runWorkerLoop
// is waiting for async callbacks from node.
}
else {
worker_1.debug('Running a single build...');
if (args.length === 0)
throw new Error('Not enough arguments');
if (!runOneBuild(args)) {
return 1;
}
}
return 0;
}
exports.main = main;
/** The one ProgramAndFileCache instance used in this process. */
const cache = new cache_1.ProgramAndFileCache(worker_1.debug);
function isCompilationTarget(bazelOpts, sf) {
if (bazelOpts.isJsTranspilation && bazelOpts.transpiledJsInputDirectory) {
// transpiledJsInputDirectory is a relative logical path, so we cannot
// compare it to the resolved, absolute path of sf here.
// compilationTargetSrc is resolved, so use that for the comparison.
return sf.fileName.startsWith(bazelOpts.compilationTargetSrc[0]);
}
return (bazelOpts.compilationTargetSrc.indexOf(sf.fileName) !== -1);
}
/**
* Gather diagnostics from TypeScript's type-checker as well as other plugins we
* install such as strict dependency checking.
*/
function gatherDiagnostics(options, bazelOpts, program, disabledTsetseRules, plugins = [], ignoreForDiagnostics = new Set()) {
const diagnostics = [];
perfTrace.wrap('type checking', () => {
if (!bazelOpts.typeCheck) {
return;
}
// These checks mirror ts.getPreEmitDiagnostics, with the important
// exception of avoiding b/30708240, which is that if you call
// program.getDeclarationDiagnostics() it somehow corrupts the emit.
perfTrace.wrap(`global diagnostics`, () => {
diagnostics.push(...program.getOptionsDiagnostics());
diagnostics.push(...program.getGlobalDiagnostics());
});
// Check if `f` is a target for type-checking.
let isTypeCheckTarget = (f) => bazelOpts.typeCheckDependencies || isCompilationTarget(bazelOpts, f);
let sourceFilesToCheck = program.getSourceFiles().filter(f => isTypeCheckTarget(f) && !ignoreForDiagnostics.has(f));
for (const sf of sourceFilesToCheck) {
perfTrace.wrap(`check ${sf.fileName}`, () => {
diagnostics.push(...program.getSyntacticDiagnostics(sf));
diagnostics.push(...program.getSemanticDiagnostics(sf));
});
perfTrace.snapshotMemoryUsage();
}
// Install extra diagnostic plugins
plugins.push(...getCommonPlugins(options, bazelOpts, program, disabledTsetseRules));
for (const plugin of plugins) {
perfTrace.wrap(`${plugin.name} diagnostics`, () => {
for (const sf of sourceFilesToCheck) {
perfTrace.wrap(`${plugin.name} checking ${sf.fileName}`, () => {
const pluginDiagnostics = plugin.getDiagnostics(sf).map((d) => {
return tagDiagnosticWithPlugin(plugin.name, d);
});
diagnostics.push(...pluginDiagnostics);
});
perfTrace.snapshotMemoryUsage();
}
});
}
});
return diagnostics;
}
exports.gatherDiagnostics = gatherDiagnostics;
/**
* Construct diagnostic plugins that we always want included.
*
* TODO: Call sites of getDiagnostics should initialize plugins themselves,
* including these, and the arguments to getDiagnostics should be simplified.
*/
function getCommonPlugins(options, bazelOpts, program, disabledTsetseRules) {
const plugins = [];
if (!bazelOpts.disableStrictDeps) {
if (options.rootDir == null) {
throw new Error(`StrictDepsPlugin requires that rootDir be specified`);
}
plugins.push(new strict_deps_1.Plugin(program, Object.assign(Object.assign({}, bazelOpts), { rootDir: options.rootDir })));
}
if (!bazelOpts.isJsTranspilation) {
let tsetsePluginConstructor = runner_1.Plugin;
plugins.push(new tsetsePluginConstructor(program, disabledTsetseRules));
}
return plugins;
}
exports.getCommonPlugins = getCommonPlugins;
/**
* Returns a copy of diagnostic with one whose text has been prepended with
* an indication of what plugin contributed that diagnostic.
*
* This is slightly complicated because a diagnostic's message text can be
* split up into a chain of diagnostics, e.g. when there's supplementary info
* about a diagnostic.
*/
function tagDiagnosticWithPlugin(pluginName, diagnostic) {
const tagMessageWithPluginName = (text) => `[${pluginName}] ${text}`;
let messageText;
if (typeof diagnostic.messageText === 'string') {
// The simple case, where a diagnostic's message is just a string.
messageText = tagMessageWithPluginName(diagnostic.messageText);
}
else {
// In the case of a chain of messages we only want to tag the head of the
// chain, as that's the first line of message on the CLI.
const chain = diagnostic.messageText;
messageText = Object.assign(Object.assign({}, chain), { messageText: tagMessageWithPluginName(chain.messageText) });
}
return Object.assign(Object.assign({}, diagnostic), { messageText });
}
/**
* expandSourcesFromDirectories finds any directories under filePath and expands
* them to their .js or .ts contents.
*/
function expandSourcesFromDirectories(fileList, filePath) {
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx') ||
filePath.endsWith('.js')) {
fileList.push(filePath);
return;
}
if (!fs.statSync(filePath).isDirectory()) {
// subdirectories may also contain e.g. .java files, which we ignore.
return;
}
const entries = fs.readdirSync(filePath);
for (const entry of entries) {
expandSourcesFromDirectories(fileList, path.join(filePath, entry));
}
}
/**
* Runs a single build, returning false on failure. This is potentially called
* multiple times (once per bazel request) when running as a bazel worker.
* Any encountered errors are written to stderr.
*/
function runOneBuild(args, inputs) {
if (args.length !== 1) {
console.error('Expected one argument: path to tsconfig.json');
return false;
}
perfTrace.snapshotMemoryUsage();
// Strip leading at-signs, used in build_defs.bzl to indicate a params file
const tsconfigFile = args[0].replace(/^@+/, '');
const [parsed, errors, { target }] = tsconfig_1.parseTsconfig(tsconfigFile);
if (errors) {
console.error(bazelDiagnostics.format(target, errors));
return false;
}
if (!parsed) {
throw new Error('Impossible state: if parseTsconfig returns no errors, then parsed should be non-null');
}
const { options, bazelOpts, files, disabledTsetseRules } = parsed;
let sourceFiles = [];
if (bazelOpts.isJsTranspilation) {
// Under JS transpilations, some inputs might be directories.
for (const filePath of files) {
expandSourcesFromDirectories(sourceFiles, filePath);
}
}
else {
sourceFiles = files;
}
if (bazelOpts.maxCacheSizeMb !== undefined) {
const maxCacheSizeBytes = bazelOpts.maxCacheSizeMb * (1 << 20);
cache.setMaxCacheSize(maxCacheSizeBytes);
}
else {
cache.resetMaxCacheSize();
}
let fileLoader;
if (inputs) {
fileLoader = new cache_1.CachedFileLoader(cache);
// Resolve the inputs to absolute paths to match TypeScript internals
const resolvedInputs = new Map();
for (const key of Object.keys(inputs)) {
resolvedInputs.set(tsconfig_1.resolveNormalizedPath(key), inputs[key]);
}
cache.updateCache(resolvedInputs);
}
else {
fileLoader = new cache_1.UncachedFileLoader();
}
const diagnostics = perfTrace.wrap('createProgramAndEmit', () => {
return createProgramAndEmit(fileLoader, options, bazelOpts, sourceFiles, disabledTsetseRules)
.diagnostics;
});
if (diagnostics.length > 0) {
console.error(bazelDiagnostics.format(bazelOpts.target, diagnostics));
return false;
}
const perfTracePath = bazelOpts.perfTracePath;
if (perfTracePath) {
worker_1.log('Writing trace to', perfTracePath);
perfTrace.snapshotMemoryUsage();
perfTrace.write(perfTracePath);
}
return true;
}
// We use the expected_diagnostics attribute for writing compilation tests.
// We don't want to expose it to users as a general-purpose feature, because
// we don't want users to end up using it like a fancy @ts-ignore.
// So instead it's limited to a whitelist.
const expectDiagnosticsWhitelist = [];
/** errorDiag produces an error diagnostic not bound to a file or location. */
function errorDiag(messageText) {
return {
category: ts.DiagnosticCategory.Error,
code: 0,
file: undefined,
start: undefined,
length: undefined,
messageText,
};
}
/**
* createProgramAndEmit creates a ts.Program from the given options and emits it
* according to them (e.g. including running various plugins and tsickle). It
* returns the program and any diagnostics generated.
*
* Callers should check and emit diagnostics.
*/
function createProgramAndEmit(fileLoader, options, bazelOpts, files, disabledTsetseRules) {
// Beware! createProgramAndEmit must not print to console, nor exit etc.
// Handle errors by reporting and returning diagnostics.
perfTrace.snapshotMemoryUsage();
cache.resetStats();
cache.traceStats();
const compilerHostDelegate = ts.createCompilerHost({ target: ts.ScriptTarget.ES5 });
const moduleResolver = bazelOpts.isJsTranspilation ?
makeJsModuleResolver(bazelOpts.workspaceName) :
ts.resolveModuleName;
// Files which should be allowed to be read, but aren't TypeScript code
const assets = [];
if (bazelOpts.angularCompilerOptions) {
if (bazelOpts.angularCompilerOptions.assets) {
assets.push(...bazelOpts.angularCompilerOptions.assets);
}
}
const tsickleCompilerHost = new compiler_host_1.CompilerHost([...files, ...assets], options, bazelOpts, compilerHostDelegate, fileLoader, moduleResolver);
let compilerHost = tsickleCompilerHost;
const diagnosticPlugins = [];
let angularPlugin;
if (bazelOpts.angularCompilerOptions) {
try {
const ngOptions = bazelOpts.angularCompilerOptions;
// Add the rootDir setting to the options passed to NgTscPlugin.
// Required so that synthetic files added to the rootFiles in the program
// can be given absolute paths, just as we do in tsconfig.ts, matching
// the behavior in TypeScript's tsconfig parsing logic.
ngOptions['rootDir'] = options.rootDir;
let angularPluginEntryPoint = '@angular/compiler-cli';
// Dynamically load the Angular compiler.
// Lazy load, so that code that does not use the plugin doesn't even
// have to spend the time to parse and load the plugin's source.
//
// tslint:disable-next-line:no-require-imports
const ngtsc = require(angularPluginEntryPoint);
angularPlugin = new ngtsc.NgTscPlugin(ngOptions);
diagnosticPlugins.push(angularPlugin);
}
catch (e) {
return {
diagnostics: [errorDiag('when using `ts_library(use_angular_plugin=True)`, ' +
`you must install @angular/compiler-cli (was: ${e})`)]
};
}
// Wrap host so that Ivy compiler can add a file to it (has synthetic types for checking templates)
// TODO(arick): remove after ngsummary and ngfactory files eliminated
compilerHost = angularPlugin.wrapHost(compilerHost, files, options);
}
const oldProgram = cache.getProgram(bazelOpts.target);
const program = perfTrace.wrap('createProgram', () => ts.createProgram(compilerHost.inputFiles, options, compilerHost, oldProgram));
cache.putProgram(bazelOpts.target, program);
let transformers = {
before: [],
after: [],
afterDeclarations: [],
};
let ignoreForDiagnostics = new Set();
if (angularPlugin) {
// The Angular plugin (via the `wrapHost` call above) inserts additional
// "shim" files into the `ts.Program`, beyond the user's .ts files. For
// proper operation, the plugin requires two modifications to the standard
// flow of TypeScript compilation, relating to which files are either
// type-checked or emitted.
//
// In tsc_wrapped, there is already a concept of which files should be
// emitted (which is calculated from the compilation inputs, as well as any
// paths that match expected Angular shims such as ngfactory files for those
// inputs). So the `ignoreForEmit` set produced by the plugin can be
// ignored here.
const angularSetup = angularPlugin.setupCompilation(program);
// Shims generated by the plugin do not benefit from normal type-checking,
// for a few reasons.
// 1) for emitted shims like ngfactory files, their proper contents are
// programmatically added via TypeScript transforms, so checking their
// initial contents is pointless and inefficient.
// 2) for non-emitted shims like the ngtypecheck files used in template
// type-checking, they are managed and checked internally via the plugin
// `getDiagnostics` method. Checking them as part of the normal
// diagnostics flow will at best produce spurious, duplicate errors that
// are not reported in the correct context, and at worst can produce
// incorrect errors.
//
// The `ignoreForDiagnostics` set informs tsc_wrapped which Angular shim
// files should be skipped when gathering diagnostics.
ignoreForDiagnostics = angularSetup.ignoreForDiagnostics;
transformers = angularPlugin.createTransformers();
}
for (const pluginConfig of options['plugins'] || []) {
if (pluginConfig.name === 'ts-lit-plugin') {
const litTscPlugin =
// Lazy load, so that code that does not use the plugin doesn't even
// have to spend the time to parse and load the plugin's source.
//
// tslint:disable-next-line:no-require-imports
new (require('ts-lit-plugin/lib/bazel-plugin').Plugin)(program, pluginConfig);
diagnosticPlugins.push(litTscPlugin);
}
}
if (!bazelOpts.isJsTranspilation) {
// If there are any TypeScript type errors abort now, so the error
// messages refer to the original source. After any subsequent passes
// (decorator downleveling or tsickle) we do not type check.
let diagnostics = gatherDiagnostics(options, bazelOpts, program, disabledTsetseRules, diagnosticPlugins, ignoreForDiagnostics);
if (!expectDiagnosticsWhitelist.length ||
expectDiagnosticsWhitelist.some(p => bazelOpts.target.startsWith(p))) {
diagnostics = bazelDiagnostics.filterExpected(bazelOpts, diagnostics, bazelDiagnostics.uglyFormat);
}
else if (bazelOpts.expectedDiagnostics.length > 0) {
diagnostics.push(errorDiag(`Only targets under ${expectDiagnosticsWhitelist.join(', ')} can use ` +
'expected_diagnostics, but got ' + bazelOpts.target));
}
// The Angular plugin creates a new program with template type-check information
// This consumes (destroys) the old program so it's not suitable for re-use anymore
// Ask Angular to give us the updated reusable program.
if (angularPlugin) {
cache.putProgram(bazelOpts.target, angularPlugin.getNextProgram());
}
if (diagnostics.length > 0) {
worker_1.debug('compilation failed at', new Error().stack);
return { program, diagnostics };
}
}
// Angular might have added files like input.ngfactory.ts or input.ngsummary.ts
// and these need to be emitted.
// TODO(arick): remove after Ivy is enabled and ngsummary/ngfactory files no longer needed
function isAngularFile(sf) {
if (!/\.ng(factory|summary)\.ts$/.test(sf.fileName)) {
return false;
}
return isCompilationTarget(bazelOpts, {
fileName: sf.fileName.slice(0, /*'.ngfactory|ngsummary.ts'.length*/ -13) + '.ts'
});
}
// If the Angular plugin is in use, this list of files to emit should exclude
// any files defined in the `ignoreForEmit` set returned by the plugin.
// However limiting the outputs to the set of compilation target files (plus
// any Angular shims defined by `isAngularFile`) already has that effect, so
// `ignoreForEmit` does not need to be factored in here.
const compilationTargets = program.getSourceFiles().filter(sf => isCompilationTarget(bazelOpts, sf) || isAngularFile(sf));
let diagnostics = [];
let useTsickleEmit = bazelOpts.tsickle;
if (useTsickleEmit) {
diagnostics = emitWithTsickle(program, tsickleCompilerHost, compilationTargets, options, bazelOpts, transformers);
}
else {
diagnostics = emitWithTypescript(program, compilationTargets, transformers);
}
if (diagnostics.length > 0) {
worker_1.debug('compilation failed at', new Error().stack);
}
cache.printStats();
return { program, diagnostics };
}
exports.createProgramAndEmit = createProgramAndEmit;
function emitWithTypescript(program, compilationTargets, transforms) {
const diagnostics = [];
for (const sf of compilationTargets) {
const result = program.emit(sf, /*writeFile*/ undefined,
/*cancellationToken*/ undefined, /*emitOnlyDtsFiles*/ undefined, transforms);
diagnostics.push(...result.diagnostics);
}
return diagnostics;
}
/**
* Runs the emit pipeline with Tsickle transformations - goog.module rewriting
* and Closure types emitted included.
* Exported to be used by the internal global refactoring tools.
* TODO(radokirov): investigate using runWithOptions and making this private
* again, if we can make compilerHosts match.
*/
function emitWithTsickle(program, compilerHost, compilationTargets, options, bazelOpts, transforms) {
const emitResults = [];
const diagnostics = [];
// The 'tsickle' import above is only used in type positions, so it won't
// result in a runtime dependency on tsickle.
// If the user requests the tsickle emit, then we dynamically require it
// here for use at runtime.
let optTsickle;
try {
// tslint:disable-next-line:no-require-imports
optTsickle = require('tsickle');
}
catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
throw new Error('When setting bazelOpts { tsickle: true }, ' +
'you must also add a devDependency on the tsickle npm package');
}
perfTrace.wrap('emit', () => {
for (const sf of compilationTargets) {
perfTrace.wrap(`emit ${sf.fileName}`, () => {
emitResults.push(optTsickle.emitWithTsickle(program, compilerHost, compilerHost, options, sf,
/*writeFile*/ undefined,
/*cancellationToken*/ undefined, /*emitOnlyDtsFiles*/ undefined, {
beforeTs: transforms.before,
afterTs: transforms.after,
afterDeclarations: transforms.afterDeclarations,
}));
});
}
});
const emitResult = optTsickle.mergeEmitResults(emitResults);
diagnostics.push(...emitResult.diagnostics);
// If tsickle reported diagnostics, don't produce externs or manifest outputs.
if (diagnostics.length > 0) {
return diagnostics;
}
let externs = '/** @externs */\n' +
'// generating externs was disabled using generate_externs=False\n';
if (bazelOpts.tsickleGenerateExterns) {
externs =
optTsickle.getGeneratedExterns(emitResult.externs, options.rootDir);
}
if (!options.noEmit && bazelOpts.tsickleExternsPath) {
// Note: when tsickleExternsPath is provided, we always write a file as a
// marker that compilation succeeded, even if it's empty (just containing an
// @externs).
fs.writeFileSync(bazelOpts.tsickleExternsPath, externs);
// When generating externs, generate an externs file for each of the input
// .d.ts files.
if (bazelOpts.tsickleGenerateExterns &&
compilerHost.provideExternalModuleDtsNamespace) {
for (const extern of compilationTargets) {
if (!extern.isDeclarationFile)
continue;
const outputBaseDir = options.outDir;
const relativeOutputPath = compilerHost.relativeOutputPath(extern.fileName);
mkdirp(outputBaseDir, path.dirname(relativeOutputPath));
const outputPath = path.join(outputBaseDir, relativeOutputPath);
const moduleName = compilerHost.pathToModuleName('', extern.fileName);
fs.writeFileSync(outputPath, `goog.module('${moduleName}');\n` +
`// Export an empty object of unknown type to allow imports.\n` +
`// TODO: use typeof once available\n` +
`exports = /** @type {?} */ ({});\n`);
}
}
}
if (!options.noEmit && bazelOpts.manifest) {
perfTrace.wrap('manifest', () => {
const manifest = manifest_1.constructManifest(emitResult.modulesManifest, compilerHost);
fs.writeFileSync(bazelOpts.manifest, manifest);
});
}
return diagnostics;
}
exports.emitWithTsickle = emitWithTsickle;
/**
* Creates directories subdir (a slash separated relative path) starting from
* base.
*/
function mkdirp(base, subdir) {
const steps = subdir.split(path.sep);
let current = base;
for (let i = 0; i < steps.length; i++) {
current = path.join(current, steps[i]);
if (!fs.existsSync(current))
fs.mkdirSync(current);
}
}
/**
* Resolve module filenames for JS modules.
*
* JS module resolution needs to be different because when transpiling JS we
* do not pass in any dependencies, so the TS module resolver will not resolve
* any files.
*
* Fortunately, JS module resolution is very simple. The imported module name
* must either a relative path, or the workspace root (i.e. 'google3'),
* so we can perform module resolution entirely based on file names, without
* looking at the filesystem.
*/
function makeJsModuleResolver(workspaceName) {
// The literal '/' here is cross-platform safe because it's matching on
// import specifiers, not file names.
const workspaceModuleSpecifierPrefix = `${workspaceName}/`;
const workspaceDir = `${path.sep}${workspaceName}${path.sep}`;
function jsModuleResolver(moduleName, containingFile, compilerOptions, host) {
let resolvedFileName;
if (containingFile === '') {
// In tsickle we resolve the filename against '' to get the goog module
// name of a sourcefile.
resolvedFileName = moduleName;
}
else if (moduleName.startsWith(workspaceModuleSpecifierPrefix)) {
// Given a workspace name of 'foo', we want to resolve import specifiers
// like: 'foo/project/file.js' to the absolute filesystem path of
// project/file.js within the workspace.
const workspaceDirLocation = containingFile.indexOf(workspaceDir);
if (workspaceDirLocation < 0) {
return { resolvedModule: undefined };
}
const absolutePathToWorkspaceDir = containingFile.slice(0, workspaceDirLocation);
resolvedFileName = path.join(absolutePathToWorkspaceDir, moduleName);
}
else {
if (!moduleName.startsWith('./') && !moduleName.startsWith('../')) {
throw new Error(`Unsupported module import specifier: ${JSON.stringify(moduleName)}.\n` +
`JS module imports must either be relative paths ` +
`(beginning with '.' or '..'), ` +
`or they must begin with '${workspaceName}/'.`);
}
resolvedFileName = path.join(path.dirname(containingFile), moduleName);
}
return {
resolvedModule: {
resolvedFileName,
extension: ts.Extension.Js,
// These two fields are cargo culted from what ts.resolveModuleName
// seems to return.
packageId: undefined,
isExternalLibraryImport: false,
}
};
}
return jsModuleResolver;
}
if (require.main === module) {
// Do not call process.exit(), as that terminates the binary before
// completing pending operations, such as writing to stdout or emitting the
// v8 performance log. Rather, set the exit code and fall off the main
// thread, which will cause node to terminate cleanly.
process.exitCode = main(process.argv.slice(2));
}