feat(ivy): introduce type-checking context API (#26203)

This commit introduces the template type-checking context API, which manages
inlining of type constructors and type-check blocks into ts.SourceFiles.
This API will be used by ngtsc to generate a type-checking ts.Program.

An TypeCheckProgramHost is provided which can wrap a normal ts.CompilerHost
and intercept getSourceFile() calls. This can be used to provide source
files with type check blocks to a ts.Program for type-checking.

PR Close #26203
This commit is contained in:
Alex Rickabaugh 2018-09-25 14:59:10 -07:00 committed by Jason Aden
parent 355a7cae3c
commit 5f1273ba2e
6 changed files with 443 additions and 15 deletions

View File

@ -10,10 +10,10 @@ import * as path from 'path';
import * as ts from 'typescript';
export function makeProgram(
files: {name: string, contents: string}[],
options?: ts.CompilerOptions): {program: ts.Program, host: ts.CompilerHost} {
const host = new InMemoryHost();
files.forEach(file => host.writeFile(file.name, file.contents));
files: {name: string, contents: string}[], options?: ts.CompilerOptions,
host: ts.CompilerHost = new InMemoryHost(),
checkForErrors: boolean = true): {program: ts.Program, host: ts.CompilerHost} {
files.forEach(file => host.writeFile(file.name, file.contents, false, undefined, []));
const rootNames = files.map(file => host.getCanonicalFileName(file.name));
const program = ts.createProgram(
@ -23,17 +23,20 @@ export function makeProgram(
moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options
},
host);
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
if (diags.length > 0) {
const errors = diags.map(diagnostic => {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (diagnostic.file) {
const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
}
return `Error: ${message}`;
});
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
if (checkForErrors) {
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
if (diags.length > 0) {
const errors = diags.map(diagnostic => {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (diagnostic.file) {
const {line, character} =
diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
}
return `Error: ${message}`;
});
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
}
}
return {program, host};
}

View File

@ -0,0 +1,11 @@
/**
* @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
*/
export * from './src/api';
export {TypeCheckContext} from './src/context';
export {TypeCheckProgramHost} from './src/host';

View File

@ -0,0 +1,240 @@
/**
* @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 {R3TargetBinder, SelectorMatcher, TmplAstNode} from '@angular/compiler';
import * as ts from 'typescript';
import {ImportManager} from '../../translator';
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta, TypeCtorMetadata} from './api';
import {generateTypeCheckBlock} from './type_check_block';
import {generateTypeCtor} from './type_constructor';
/**
* A template type checking context for a program.
*
* The `TypeCheckContext` allows registration of components and their templates which need to be
* type checked. It also allows generation of modified `ts.SourceFile`s which contain the type
* checking code.
*/
export class TypeCheckContext {
/**
* A `Set` of classes which will be used to generate type constructors.
*/
private typeCtors = new Set<ts.ClassDeclaration>();
/**
* A `Map` of `ts.SourceFile`s that the context has seen to the operations (additions of methods
* or type-check blocks) that need to be eventually performed on that file.
*/
private opMap = new Map<ts.SourceFile, Op[]>();
/**
* Record a template for the given component `node`, with a `SelectorMatcher` for directive
* matching.
*
* @param node class of the node being recorded.
* @param template AST nodes of the template being recorded.
* @param matcher `SelectorMatcher` which tracks directives that are in scope for this template.
*/
addTemplate(
node: ts.ClassDeclaration, template: TmplAstNode[],
matcher: SelectorMatcher<TypeCheckableDirectiveMeta>): void {
// Only write TCBs for named classes.
if (node.name === undefined) {
throw new Error(`Assertion: class must be named`);
}
// Bind the template, which will:
// - Extract the metadata needed to generate type check blocks.
// - Perform directive matching, which informs the context which directives are used in the
// template. This allows generation of type constructors for only those directives which
// are actually used by the templates.
const binder = new R3TargetBinder(matcher);
const boundTarget = binder.bind({template});
// Get all of the directives used in the template and record type constructors for all of them.
boundTarget.getUsedDirectives().forEach(dir => {
const dirNode = dir.ref.node;
// Add a type constructor operation for the directive.
this.addTypeCtor(dirNode.getSourceFile(), dirNode, {
fnName: 'ngTypeCtor',
// The constructor should have a body if the directive comes from a .ts file, but not if it
// comes from a .d.ts file. .d.ts declarations don't have bodies.
body: !dirNode.getSourceFile().fileName.endsWith('.d.ts'),
fields: {
inputs: Object.keys(dir.inputs),
outputs: Object.keys(dir.outputs),
// TODO: support queries
queries: dir.queries,
},
});
});
// Record the type check block operation for the template itself.
this.addTypeCheckBlock(node.getSourceFile(), node, {
boundTarget,
fnName: `${node.name.text}_TypeCheckBlock`,
});
}
/**
* Record a type constructor for the given `node` with the given `ctorMetadata`.
*/
addTypeCtor(sf: ts.SourceFile, node: ts.ClassDeclaration, ctorMeta: TypeCtorMetadata): void {
if (this.hasTypeCtor(node)) {
return;
}
// Lazily construct the operation map.
if (!this.opMap.has(sf)) {
this.opMap.set(sf, []);
}
const ops = this.opMap.get(sf) !;
// Push a `TypeCtorOp` into the operation queue for the source file.
ops.push(new TypeCtorOp(node, ctorMeta));
}
/**
* Transform a `ts.SourceFile` into a version that includes type checking code.
*
* If this particular source file has no directives that require type constructors, or components
* that require type check blocks, then it will be returned directly. Otherwise, a new
* `ts.SourceFile` is parsed from modified text of the original. This is necessary to ensure the
* added code has correct positional information associated with it.
*/
transform(sf: ts.SourceFile): ts.SourceFile {
// If there are no operations pending for this particular file, return it directly.
if (!this.opMap.has(sf)) {
return sf;
}
// Imports may need to be added to the file to support type-checking of directives used in the
// template within it.
const importManager = new ImportManager(false, '_i');
// Each Op has a splitPoint index into the text where it needs to be inserted. Split the
// original source text into chunks at these split points, where code will be inserted between
// the chunks.
const ops = this.opMap.get(sf) !.sort(orderOps);
const textParts = splitStringAtPoints(sf.text, ops.map(op => op.splitPoint));
// Use a `ts.Printer` to generate source code.
const printer = ts.createPrinter({omitTrailingSemicolon: true});
// Begin with the intial section of the code text.
let code = textParts[0];
// Process each operation and use the printer to generate source code for it, inserting it into
// the source code in between the original chunks.
ops.forEach((op, idx) => {
const text = op.execute(importManager, sf, printer);
code += text + textParts[idx + 1];
});
// Write out the imports that need to be added to the beginning of the file.
let imports = importManager.getAllImports(sf.fileName, null)
.map(i => `import * as ${i.as} from '${i.name}';`)
.join('\n');
code = imports + '\n' + code;
// Parse the new source file and return it.
return ts.createSourceFile(sf.fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
}
/**
* Whether the given `node` has a type constructor already.
*/
private hasTypeCtor(node: ts.ClassDeclaration): boolean { return this.typeCtors.has(node); }
private addTypeCheckBlock(
sf: ts.SourceFile, node: ts.ClassDeclaration, tcbMeta: TypeCheckBlockMetadata): void {
if (!this.opMap.has(sf)) {
this.opMap.set(sf, []);
}
const ops = this.opMap.get(sf) !;
ops.push(new TcbOp(node, tcbMeta));
}
}
/**
* A code generation operation that needs to happen within a given source file.
*/
interface Op {
/**
* The node in the file which will have code generated for it.
*/
readonly node: ts.ClassDeclaration;
/**
* Index into the source text where the code generated by the operation should be inserted.
*/
readonly splitPoint: number;
/**
* Execute the operation and return the generated code as text.
*/
execute(im: ImportManager, sf: ts.SourceFile, printer: ts.Printer): string;
}
/**
* A type check block operation which produces type check code for a particular component.
*/
class TcbOp implements Op {
constructor(readonly node: ts.ClassDeclaration, readonly meta: TypeCheckBlockMetadata) {}
/**
* Type check blocks are inserted immediately after the end of the component class.
*/
get splitPoint(): number { return this.node.end + 1; }
execute(im: ImportManager, sf: ts.SourceFile, printer: ts.Printer): string {
const tcb = generateTypeCheckBlock(this.node, this.meta, im);
return printer.printNode(ts.EmitHint.Unspecified, tcb, sf);
}
}
/**
* A type constructor operation which produces type constructor code for a particular directive.
*/
class TypeCtorOp implements Op {
constructor(readonly node: ts.ClassDeclaration, readonly meta: TypeCtorMetadata) {}
/**
* Type constructor operations are inserted immediately before the end of the directive class.
*/
get splitPoint(): number { return this.node.end - 1; }
execute(im: ImportManager, sf: ts.SourceFile, printer: ts.Printer): string {
const tcb = generateTypeCtor(this.node, this.meta);
return printer.printNode(ts.EmitHint.Unspecified, tcb, sf);
}
}
/**
* Compare two operations and return their split point ordering.
*/
function orderOps(op1: Op, op2: Op): number {
return op1.splitPoint - op2.splitPoint;
}
/**
* Split a string into chunks at any number of split points.
*/
function splitStringAtPoints(str: string, points: number[]): string[] {
const splits: string[] = [];
let start = 0;
for (let i = 0; i < points.length; i++) {
const point = points[i];
splits.push(str.substring(start, point));
start = point;
}
splits.push(str.substring(start));
return splits;
}

View File

@ -0,0 +1,93 @@
/**
* @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 {TypeCheckContext} from './context';
/**
* A `ts.CompilerHost` which augments source files with type checking code from a
* `TypeCheckContext`.
*/
export class TypeCheckProgramHost implements ts.CompilerHost {
/**
* Map of source file names to `ts.SourceFile` instances.
*
* This is prepopulated with all the old source files, and updated as files are augmented.
*/
private sfCache = new Map<string, ts.SourceFile>();
/**
* Tracks those files in `sfCache` which have been augmented with type checking information
* already.
*/
private augmentedSourceFiles = new Set<ts.SourceFile>();
constructor(
program: ts.Program, private delegate: ts.CompilerHost, private context: TypeCheckContext) {
// The `TypeCheckContext` uses object identity for `ts.SourceFile`s to track which files need
// type checking code inserted. Additionally, the operation of getting a source file should be
// as efficient as possible. To support both of these requirements, all of the program's
// source files are loaded into the cache up front.
program.getSourceFiles().forEach(file => { this.sfCache.set(file.fileName, file); });
}
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: ((message: string) => void)|undefined,
shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined {
// Look in the cache for the source file.
let sf: ts.SourceFile|undefined = this.sfCache.get(fileName);
if (sf === undefined) {
// There should be no cache misses, but just in case, delegate getSourceFile in the event of
// a cache miss.
sf = this.delegate.getSourceFile(
fileName, languageVersion, onError, shouldCreateNewSourceFile);
sf && this.sfCache.set(fileName, sf);
}
if (sf !== undefined) {
// Maybe augment the file with type checking code via the `TypeCheckContext`.
if (!this.augmentedSourceFiles.has(sf)) {
sf = this.context.transform(sf);
this.sfCache.set(fileName, sf);
this.augmentedSourceFiles.add(sf);
}
return sf;
} else {
return undefined;
}
}
// The rest of the methods simply delegate to the underlying `ts.CompilerHost`.
getDefaultLibFileName(options: ts.CompilerOptions): string {
return this.delegate.getDefaultLibFileName(options);
}
writeFile(
fileName: string, data: string, writeByteOrderMark: boolean,
onError: ((message: string) => void)|undefined,
sourceFiles: ReadonlyArray<ts.SourceFile>): void {
return this.delegate.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles);
}
getCurrentDirectory(): string { return this.delegate.getCurrentDirectory(); }
getDirectories(path: string): string[] { return this.delegate.getDirectories(path); }
getCanonicalFileName(fileName: string): string {
return this.delegate.getCanonicalFileName(fileName);
}
useCaseSensitiveFileNames(): boolean { return this.delegate.useCaseSensitiveFileNames(); }
getNewLine(): string { return this.delegate.getNewLine(); }
fileExists(fileName: string): boolean { return this.delegate.fileExists(fileName); }
readFile(fileName: string): string|undefined { return this.delegate.readFile(fileName); }
}

View File

@ -0,0 +1,27 @@
package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
ts_library(
name = "test_lib",
testonly = 1,
srcs = glob([
"**/*.ts",
]),
deps = [
"//packages:types",
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/host",
"//packages/compiler-cli/src/ngtsc/testing",
"//packages/compiler-cli/src/ngtsc/typecheck",
],
)
jasmine_node_test(
name = "test",
bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"],
deps = [
":test_lib",
"//tools/testing:node_no_angular",
],
)

View File

@ -0,0 +1,54 @@
/**
* @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 {getDeclaration, makeProgram} from '../../testing/in_memory_typescript';
import {TypeCheckContext} from '../src/context';
import {TypeCheckProgramHost} from '../src/host';
const LIB_D_TS = {
name: 'lib.d.ts',
contents: `
type Partial<T> = { [P in keyof T]?: T[P]; };
type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
type NonNullable<T> = T extends null | undefined ? never : T;`
};
describe('ngtsc typechecking', () => {
describe('ctors', () => {
it('compiles a basic type constructor', () => {
const ctx = new TypeCheckContext();
const files = [
LIB_D_TS, {
name: 'main.ts',
contents: `
class TestClass<T extends string> {
value: T;
}
TestClass.ngTypeCtor({value: 'test'});
`
}
];
const {program, host} = makeProgram(files, undefined, undefined, false);
const TestClass = getDeclaration(program, 'main.ts', 'TestClass', ts.isClassDeclaration);
ctx.addTypeCtor(program.getSourceFile('main.ts') !, TestClass, {
fnName: 'ngTypeCtor',
body: true,
fields: {
inputs: ['value'],
outputs: [],
queries: [],
},
});
const augHost = new TypeCheckProgramHost(program, host, ctx);
makeProgram(files, undefined, augHost, true);
});
});
});