feat(compiler): reuse the TypeScript typecheck for template typechecking. (#19152)

This speeds up the compilation process significantly.

Also introduces a new option `fullTemplateTypeCheck` to do more checks in templates:
- check expressions inside of templatized content (e.g. inside of `<div *ngIf>`).
- check the arguments of calls to the `transform` function of pipes
- check references to directives that were exposed as variables via `exportAs`
PR Close #19152
This commit is contained in:
Tobias Bosch
2017-09-11 15:18:19 -07:00
committed by Matias Niemelä
parent 554fe65690
commit 996c7c2dde
22 changed files with 712 additions and 401 deletions

View File

@ -6,95 +6,200 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompilerOptions, createAotCompiler} from '@angular/compiler';
import {EmittingCompilerHost, MockAotCompilerHost, MockCompilerHost, MockData, MockDirectory, MockMetadataBundlerHost, arrayToMockDir, arrayToMockMap, isSource, settings, setup, toMockFileArray} from '@angular/compiler/test/aot/test_util';
import * as ng from '@angular/compiler-cli';
import {makeTempDir} from '@angular/tsc-wrapped/test/test_support';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {TypeChecker} from '../../src/diagnostics/check_types';
import {Diagnostic} from '../../src/transformers/api';
import {LowerMetadataCache} from '../../src/transformers/lower_expressions';
function compile(
rootDirs: MockData, options: AotCompilerOptions = {},
tsOptions: ts.CompilerOptions = {}): Diagnostic[] {
const rootDirArr = toMockFileArray(rootDirs);
const scriptNames = rootDirArr.map(entry => entry.fileName).filter(isSource);
const host = new MockCompilerHost(scriptNames, arrayToMockDir(rootDirArr));
const aotHost = new MockAotCompilerHost(host, new LowerMetadataCache({}));
const tsSettings = {...settings, ...tsOptions};
const program = ts.createProgram(host.scriptNames.slice(0), tsSettings, host);
const ngChecker = new TypeChecker(program, tsSettings, host, aotHost, options);
return ngChecker.getDiagnostics();
function getNgRootDir() {
const moduleFilename = module.filename.replace(/\\/g, '/');
const distIndex = moduleFilename.indexOf('/dist/all');
return moduleFilename.substr(0, distIndex);
}
describe('ng type checker', () => {
let angularFiles = setup();
let basePath: string;
let write: (fileName: string, content: string) => void;
let errorSpy: jasmine.Spy&((s: string) => void);
function accept(...files: MockDirectory[]) {
expectNoDiagnostics(compile([angularFiles, QUICKSTART, ...files]));
function compileAndCheck(
mockDirs: {[fileName: string]: string}[],
overrideOptions: ng.CompilerOptions = {}): ng.Diagnostics {
const fileNames: string[] = [];
mockDirs.forEach((dir) => {
Object.keys(dir).forEach((fileName) => {
if (fileName.endsWith('.ts')) {
fileNames.push(path.resolve(basePath, fileName));
}
write(fileName, dir[fileName]);
});
});
const options: ng.CompilerOptions = {
basePath,
'experimentalDecorators': true,
'skipLibCheck': true,
'strict': true,
'types': [],
'outDir': path.resolve(basePath, 'built'),
'rootDir': basePath,
'baseUrl': basePath,
'declaration': true,
'target': ts.ScriptTarget.ES5,
'module': ts.ModuleKind.ES2015,
'moduleResolution': ts.ModuleResolutionKind.NodeJs,
'lib': [
path.resolve(basePath, 'node_modules/typescript/lib/lib.es6.d.ts'),
path.resolve(basePath, 'node_modules/typescript/lib/lib.dom.d.ts')
],
'typeRoots': [path.resolve(basePath, 'node_modules/@types')], ...overrideOptions
};
const {diagnostics} = ng.performCompilation({rootNames: fileNames, options});
return diagnostics;
}
function reject(message: string | RegExp, ...files: MockDirectory[]) {
const diagnostics = compile([angularFiles, QUICKSTART, ...files]);
beforeEach(() => {
errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error);
basePath = makeTempDir();
write = (fileName: string, content: string) => {
const dir = path.dirname(fileName);
if (dir != '.') {
const newDir = path.join(basePath, dir);
if (!fs.existsSync(newDir)) fs.mkdirSync(newDir);
}
fs.writeFileSync(path.join(basePath, fileName), content, {encoding: 'utf-8'});
};
const ngRootDir = getNgRootDir();
const nodeModulesPath = path.resolve(basePath, 'node_modules');
fs.mkdirSync(nodeModulesPath);
fs.symlinkSync(
path.resolve(ngRootDir, 'dist', 'all', '@angular'),
path.resolve(nodeModulesPath, '@angular'));
fs.symlinkSync(
path.resolve(ngRootDir, 'node_modules', 'rxjs'), path.resolve(nodeModulesPath, 'rxjs'));
fs.symlinkSync(
path.resolve(ngRootDir, 'node_modules', 'typescript'),
path.resolve(nodeModulesPath, 'typescript'));
});
function accept(
files: {[fileName: string]: string} = {}, overrideOptions: ng.CompilerOptions = {}) {
expectNoDiagnostics(compileAndCheck([QUICKSTART, files], overrideOptions));
}
function reject(
message: string | RegExp, location: RegExp, files: {[fileName: string]: string},
overrideOptions: ng.CompilerOptions = {}) {
const diagnostics = compileAndCheck([QUICKSTART, files], overrideOptions);
if (!diagnostics || !diagnostics.length) {
throw new Error('Expected a diagnostic erorr message');
} else {
const matches: (d: Diagnostic) => boolean = typeof message === 'string' ?
d => d.messageText == message :
d => message.test(d.messageText);
const matchingDiagnostics = diagnostics.filter(matches);
const matches: (d: ng.Diagnostic) => boolean = typeof message === 'string' ?
d => ng.isNgDiagnostic(d)&& d.messageText == message :
d => ng.isNgDiagnostic(d) && message.test(d.messageText);
const matchingDiagnostics = diagnostics.filter(matches) as ng.Diagnostic[];
if (!matchingDiagnostics || !matchingDiagnostics.length) {
throw new Error(
`Expected a diagnostics matching ${message}, received\n ${diagnostics.map(d => d.messageText).join('\n ')}`);
}
const span = matchingDiagnostics[0].span;
if (!span) {
throw new Error('Expected a sourceSpan');
}
expect(`${span.start.file.url}@${span.start.line}:${span.start.offset}`).toMatch(location);
}
}
it('should accept unmodified QuickStart', () => { accept(); });
describe('with modified quickstart', () => {
function a(template: string) {
accept({quickstart: {app: {'app.component.ts': appComponentSource(template)}}});
it('should accept unmodified QuickStart with tests for unused variables', () => {
accept({}, {
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
});
});
describe('with modified quickstart (fullTemplateTypeCheck: false)', () => {
addTests({fullTemplateTypeCheck: false});
});
describe('with modified quickstart (fullTemplateTypeCheck: true)', () => {
addTests({fullTemplateTypeCheck: true});
});
function addTests(config: {fullTemplateTypeCheck: boolean}) {
function a(template: string) { accept({'src/app.component.html': template}, config); }
function r(template: string, message: string | RegExp, location: string) {
reject(
message, new RegExp(`app\.component\.html\@${location}$`),
{'src/app.component.html': template}, config);
}
function r(template: string, message: string | RegExp) {
reject(message, {quickstart: {app: {'app.component.ts': appComponentSource(template)}}});
function rejectOnlyWithFullTemplateTypeCheck(
template: string, message: string | RegExp, location: string) {
if (config.fullTemplateTypeCheck) {
r(template, message, location);
} else {
a(template);
}
}
it('should report an invalid field access',
() => { r('{{fame}}', `Property 'fame' does not exist on type 'AppComponent'.`); });
it('should report an invalid field access', () => {
r('<div>{{fame}}<div>', `Property 'fame' does not exist on type 'AppComponent'.`, '0:5');
});
it('should reject a reference to a field of a nullable',
() => { r('{{maybePerson.name}}', `Object is possibly 'undefined'.`); });
() => { r('<div>{{maybePerson.name}}</div>', `Object is possibly 'undefined'.`, '0:5'); });
it('should accept a reference to a field of a nullable using using non-null-assert',
() => { a('{{maybePerson!.name}}'); });
it('should accept a safe property access of a nullable person',
() => { a('{{maybePerson?.name}}'); });
it('should accept a function call', () => { a('{{getName()}}'); });
it('should reject an invalid method', () => {
r('{{getFame()}}',
`Property 'getFame' does not exist on type 'AppComponent'. Did you mean 'getName'?`);
r('<div>{{getFame()}}</div>',
`Property 'getFame' does not exist on type 'AppComponent'. Did you mean 'getName'?`, '0:5');
});
it('should accept a field access of a method result', () => { a('{{getPerson().name}}'); });
it('should reject an invalid field reference of a method result',
() => { r('{{getPerson().fame}}', `Property 'fame' does not exist on type 'Person'.`); });
it('should reject an access to a nullable field of a method result',
() => { r('{{getMaybePerson().name}}', `Object is possibly 'undefined'.`); });
it('should reject an invalid field reference of a method result', () => {
r('<div>{{getPerson().fame}}</div>', `Property 'fame' does not exist on type 'Person'.`,
'0:5');
});
it('should reject an access to a nullable field of a method result', () => {
r('<div>{{getMaybePerson().name}}</div>', `Object is possibly 'undefined'.`, '0:5');
});
it('should accept a nullable assert of a nullable field refernces of a method result',
() => { a('{{getMaybePerson()!.name}}'); });
it('should accept a safe property access of a nullable field reference of a method result',
() => { a('{{getMaybePerson()?.name}}'); });
});
it('should report an invalid field access inside of an ng-template', () => {
rejectOnlyWithFullTemplateTypeCheck(
'<ng-template>{{fame}}</ng-template>',
`Property 'fame' does not exist on type 'AppComponent'.`, '0:13');
});
it('should report an invalid call to a pipe', () => {
rejectOnlyWithFullTemplateTypeCheck(
'<div>{{"hello" | aPipe}}</div>',
`Argument of type '"hello"' is not assignable to parameter of type 'number'.`, '0:5');
});
it('should report an invalid property on an exportAs directive', () => {
rejectOnlyWithFullTemplateTypeCheck(
'<div aDir #aDir="aDir">{{aDir.fname}}</div>',
`Property 'fname' does not exist on type 'ADirective'. Did you mean 'name'?`, '0:23');
});
}
describe('with lowered expressions', () => {
it('should not report lowered expressions as errors', () => {
expectNoDiagnostics(compile([angularFiles, LOWERING_QUICKSTART]));
});
it('should not report lowered expressions as errors',
() => { expectNoDiagnostics(compileAndCheck([LOWERING_QUICKSTART])); });
});
});
function appComponentSource(template: string): string {
function appComponentSource(): string {
return `
import {Component} from '@angular/core';
import {Component, Pipe, Directive} from '@angular/core';
export interface Person {
name: string;
@ -109,7 +214,7 @@ function appComponentSource(template: string): string {
}
@Component({
template: '${template}'
templateUrl: './app.component.html'
})
export class AppComponent {
name = 'Angular';
@ -119,63 +224,69 @@ function appComponentSource(template: string): string {
getName(): string { return this.name; }
getPerson(): Person { return this.person; }
getMaybePerson(): Person | undefined { this.maybePerson; }
getMaybePerson(): Person | undefined { return this.maybePerson; }
}
@Pipe({
name: 'aPipe',
})
export class APipe {
transform(n: number): number { return n + 1; }
}
@Directive({
selector: '[aDir]',
exportAs: 'aDir'
})
export class ADirective {
name = 'ADirective';
}
`;
}
const QUICKSTART: MockDirectory = {
quickstart: {
app: {
'app.component.ts': appComponentSource('<h1>Hello {{name}}</h1>'),
'app.module.ts': `
import { NgModule } from '@angular/core';
import { toString } from './utils';
const QUICKSTART = {
'src/app.component.ts': appComponentSource(),
'src/app.component.html': '<h1>Hello {{name}}</h1>',
'src/app.module.ts': `
import { NgModule } from '@angular/core';
import { AppComponent, APipe, ADirective } from './app.component';
import { AppComponent } from './app.component';
@NgModule({
declarations: [ AppComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
`
}
}
@NgModule({
declarations: [ AppComponent, APipe, ADirective ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
`
};
const LOWERING_QUICKSTART: MockDirectory = {
quickstart: {
app: {
'app.component.ts': appComponentSource('<h1>Hello {{name}}</h1>'),
'app.module.ts': `
import { NgModule, Component } from '@angular/core';
import { toString } from './utils';
const LOWERING_QUICKSTART = {
'src/app.component.ts': appComponentSource(),
'src/app.component.html': '<h1>Hello {{name}}</h1>',
'src/app.module.ts': `
import { NgModule, Component } from '@angular/core';
import { AppComponent } from './app.component';
import { AppComponent, APipe, ADirective } from './app.component';
class Foo {}
class Foo {}
@Component({
template: '',
providers: [
{provide: 'someToken', useFactory: () => new Foo()}
]
})
export class Bar {}
@Component({
template: '',
providers: [
{provide: 'someToken', useFactory: () => new Foo()}
]
})
export class Bar {}
@NgModule({
declarations: [ AppComponent, Bar ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
`
}
}
@NgModule({
declarations: [ AppComponent, APipe, ADirective, Bar ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
`
};
function expectNoDiagnostics(diagnostics: Diagnostic[]) {
function expectNoDiagnostics(diagnostics: ng.Diagnostics) {
if (diagnostics && diagnostics.length) {
throw new Error(diagnostics.map(d => `${d.span}: ${d.messageText}`).join('\n'));
throw new Error(ng.formatDiagnostics({}, diagnostics));
}
}