feat(compiler-cli): implement NgTscPlugin on top of the NgCompiler API (#34792)

This commit implements an experimental integration with tsc_wrapped, where
it can load the Angular compiler as a plugin and perform Angular
transpilation at a user's request.

This is an alternative to the current ngc_wrapped mechanism, which is a fork
of tsc_wrapped from several years ago. tsc_wrapped has improved
significantly since then, and this feature will allow Angular to benefit
from those improvements.

Currently the plugin API between tsc_wrapped and the Angular compiler is a
work in progress, so NgTscPlugin does not yet implement any interfaces from
@bazel/typescript (the home of tsc_wrapped). Instead, an interface is
defined locally to guide this standardization.

PR Close #34792
This commit is contained in:
Alex Rickabaugh 2020-01-17 16:00:44 -08:00 committed by Misko Hevery
parent 14aa6d090e
commit 3c69442dbd
3 changed files with 87 additions and 158 deletions

View File

@ -2,7 +2,7 @@
This package contains the core functionality of the Angular compiler. It provides APIs for the implementor of a TypeScript compiler to provide Angular compilation as well.
It supports the 'ngc' command-line tool and the Angular CLI (via the `NgtscProgram`).
It supports the 'ngc' command-line tool and the Angular CLI (via the `NgtscProgram`), as well as an experimental integration with `tsc_wrapped` and the `ts_library` Bazel rule via `NgTscPlugin`.
# Angular compilation

View File

@ -1,99 +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 {PluginCompilerHost} from '@bazel/typescript/internal/tsc_wrapped/plugin_api';
import * as ts from 'typescript';
/**
* Extension of the TypeScript compiler host that supports files added to the Program which
* were never on disk.
*
* This is used for backwards-compatibility with the ViewEngine compiler, which used ngsummary
* and ngfactory files as inputs to the program. We call these inputs "synthetic".
*
* They need to be program inputs because user code may import from these generated files.
*
* TODO(alxhub): remove this after all ng_module users have migrated to Ivy
*/
export class SyntheticFilesCompilerHost implements PluginCompilerHost {
/**
* SourceFiles which are added to the program but which never existed on disk.
*/
syntheticFiles = new Map<string, ts.SourceFile>();
constructor(
private rootFiles: string[], private delegate: ts.CompilerHost,
generatedFiles: (rootFiles: string[]) => {
[fileName: string]: (host: ts.CompilerHost) => ts.SourceFile | undefined
}) {
// Allow ngtsc to contribute in-memory synthetic files, which will be loaded
// as if they existed on disk as action inputs.
const angularGeneratedFiles = generatedFiles !(rootFiles);
for (const f of Object.keys(angularGeneratedFiles)) {
const generator = angularGeneratedFiles[f];
const generated = generator(delegate);
if (generated) {
this.syntheticFiles.set(generated.fileName, generated);
}
}
if (delegate.getDirectories !== undefined) {
this.getDirectories = (path: string) => delegate.getDirectories !(path);
}
}
fileExists(filePath: string): boolean {
if (this.syntheticFiles.has(filePath)) {
return true;
}
return this.delegate.fileExists(filePath);
}
/** Loads a source file from in-memory map, or delegates. */
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: (message: string) => void): ts.SourceFile|undefined {
const syntheticFile = this.syntheticFiles.get(fileName);
if (syntheticFile) {
return syntheticFile !;
}
return this.delegate.getSourceFile(fileName, languageVersion, onError);
}
get inputFiles() { return [...this.rootFiles, ...Array.from(this.syntheticFiles.keys())]; }
fileNameToModuleId(fileName: string) {
return fileName; // TODO: Ivy logic. don't forget that the delegate has the google3 logic
}
// Delegate everything else to the original compiler host.
getDefaultLibFileName(options: ts.CompilerOptions): string {
return this.delegate.getDefaultLibFileName(options);
}
writeFile(
fileName: string, content: string, writeByteOrderMark: boolean,
onError: ((message: string) => void)|undefined,
sourceFiles: ReadonlyArray<ts.SourceFile>|undefined): void {
this.delegate.writeFile(fileName, content, writeByteOrderMark, onError, sourceFiles);
}
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) => string[];
readFile(fileName: string): string|undefined { return this.delegate.readFile(fileName); }
trace(s: string): void { console.error(s); }
}

View File

@ -6,77 +6,105 @@
* found in the LICENSE file at https://angular.io/license
*/
import {PluginCompilerHost, TscPlugin} from '@bazel/typescript/internal/tsc_wrapped/plugin_api';
import * as ts from 'typescript';
import {SyntheticFilesCompilerHost} from './synthetic_files_compiler_host';
import {NgCompiler, NgCompilerHost} from './core';
import {NgCompilerOptions, UnifiedModulesHost} from './core/api';
import {NodeJSFileSystem, setFileSystem} from './file_system';
import {NOOP_PERF_RECORDER} from './perf';
// Copied from tsc_wrapped/plugin_api.ts to avoid a runtime dependency on the
// @bazel/typescript package - it would be strange for non-Bazel users of
// Angular to fetch that package.
function createProxy<T>(delegate: T): T {
const proxy = Object.create(null);
for (const k of Object.keys(delegate)) {
proxy[k] = function() { return (delegate as any)[k].apply(delegate, arguments); };
}
return proxy;
// The following is needed to fix a the chicken-and-egg issue where the sync (into g3) script will
// refuse to accept this file unless the following string appears:
// import * as plugin from '@bazel/typescript/internal/tsc_wrapped/plugin_api';
/**
* A `ts.CompilerHost` which also returns a list of input files, out of which the `ts.Program`
* should be created.
*
* Currently mirrored from @bazel/typescript/internal/tsc_wrapped/plugin_api (with the naming of
* `fileNameToModuleName` corrected).
*/
interface PluginCompilerHost extends ts.CompilerHost, Partial<UnifiedModulesHost> {
readonly inputFiles: ReadonlyArray<string>;
}
/**
* Mirrors the plugin interface from tsc_wrapped which is currently under active development. To
* enable progress to be made in parallel, the upstream interface isn't implemented directly.
* Instead, `TscPlugin` here is structurally assignable to what tsc_wrapped expects.
*/
interface TscPlugin {
readonly name: string;
wrapHost(
host: ts.CompilerHost&Partial<UnifiedModulesHost>, inputFiles: ReadonlyArray<string>,
options: ts.CompilerOptions): PluginCompilerHost;
setupCompilation(program: ts.Program, oldProgram?: ts.Program): {
ignoreForDiagnostics: Set<ts.SourceFile>,
ignoreForEmit: Set<ts.SourceFile>,
};
getDiagnostics(file?: ts.SourceFile): ts.Diagnostic[];
getOptionDiagnostics(): ts.Diagnostic[];
getNextProgram(): ts.Program;
prepareEmit(): {
transformers: ts.CustomTransformers,
};
}
/**
* A plugin for `tsc_wrapped` which allows Angular compilation from a plain `ts_library`.
*/
export class NgTscPlugin implements TscPlugin {
constructor(private angularCompilerOptions: unknown) {}
name = 'ngtsc';
wrapHost(inputFiles: string[], compilerHost: ts.CompilerHost) {
return new SyntheticFilesCompilerHost(inputFiles, compilerHost, (rootFiles: string[]) => {
// For demo purposes, assume that the first .ts rootFile is the only
// one that needs ngfactory.js/d.ts back-compat files produced.
const tsInputs = rootFiles.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'));
const factoryPath: string = tsInputs[0].replace(/\.ts/, '.ngfactory.ts');
private options: NgCompilerOptions|null = null;
private host: NgCompilerHost|null = null;
private _compiler: NgCompiler|null = null;
return {
factoryPath: (host: ts.CompilerHost) =>
ts.createSourceFile(factoryPath, 'contents', ts.ScriptTarget.ES5),
};
});
get compiler(): NgCompiler {
if (this._compiler === null) {
throw new Error('Lifecycle error: setupCompilation() must be called first.');
}
return this._compiler;
}
wrap(program: ts.Program, config: {}, host: ts.CompilerHost) {
const proxy = createProxy(program);
proxy.getSemanticDiagnostics = (sourceFile: ts.SourceFile) => {
const result: ts.Diagnostic[] = [...program.getSemanticDiagnostics(sourceFile)];
constructor(private ngOptions: {}) { setFileSystem(new NodeJSFileSystem()); }
// For demo purposes, trigger a diagnostic when the sourcefile has a magic string
if (sourceFile.text.indexOf('diag') >= 0) {
const fake: ts.Diagnostic = {
file: sourceFile,
start: 0,
length: 3,
messageText: 'Example Angular Compiler Diagnostic',
category: ts.DiagnosticCategory.Error,
code: 12345,
// source is the name of the plugin.
source: 'ngtsc',
};
result.push(fake);
}
return result;
wrapHost(
host: ts.CompilerHost&UnifiedModulesHost, inputFiles: readonly string[],
options: ts.CompilerOptions): PluginCompilerHost {
this.options = {...this.ngOptions, ...options } as NgCompilerOptions;
this.host = NgCompilerHost.wrap(host, inputFiles, this.options);
return this.host;
}
setupCompilation(program: ts.Program, oldProgram?: ts.Program): {
ignoreForDiagnostics: Set<ts.SourceFile>,
ignoreForEmit: Set<ts.SourceFile>,
} {
if (this.host === null || this.options === null) {
throw new Error('Lifecycle error: setupCompilation() before wrapHost().');
}
this._compiler =
new NgCompiler(this.host, this.options, program, oldProgram, NOOP_PERF_RECORDER);
return {
ignoreForDiagnostics: this._compiler.ignoreForDiagnostics,
ignoreForEmit: this._compiler.ignoreForEmit,
};
return proxy;
}
createTransformers(host: PluginCompilerHost) {
const afterDeclarations: Array<ts.TransformerFactory<ts.SourceFile|ts.Bundle>> =
[(context: ts.TransformationContext) => (sf: ts.SourceFile | ts.Bundle) => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isClassDeclaration(node)) {
// For demo purposes, transform the class name in the .d.ts output
return ts.updateClassDeclaration(
node, node.decorators, node.modifiers, ts.createIdentifier('NEWNAME'),
node.typeParameters, node.heritageClauses, node.members);
}
return ts.visitEachChild(node, visitor, context);
};
return visitor(sf) as ts.SourceFile;
}];
return {afterDeclarations};
getDiagnostics(file?: ts.SourceFile): ts.Diagnostic[] {
return this.compiler.getDiagnostics(file);
}
getOptionDiagnostics(): ts.Diagnostic[] { return this.compiler.getOptionDiagnostics(); }
getNextProgram(): ts.Program { return this.compiler.getNextProgram(); }
prepareEmit(): {transformers: ts.CustomTransformers;} { return this.compiler.prepareEmit(); }
}