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

@ -1,231 +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 {AotCompiler, AotCompilerHost, AotCompilerOptions, EmitterVisitorContext, GeneratedFile, NgAnalyzedModules, ParseSourceSpan, Statement, StaticReflector, TypeScriptEmitter, createAotCompiler} from '@angular/compiler';
import * as ts from 'typescript';
import {DEFAULT_ERROR_CODE, Diagnostic, SOURCE} from '../transformers/api';
interface FactoryInfo {
source: ts.SourceFile;
context: EmitterVisitorContext;
}
type FactoryInfoMap = Map<string, FactoryInfo>;
const stubCancellationToken: ts.CancellationToken = {
isCancellationRequested(): boolean{return false;},
throwIfCancellationRequested(): void{}
};
export class TypeChecker {
private _aotCompiler: AotCompiler|undefined;
private _reflector: StaticReflector|undefined;
private _factories: Map<string, FactoryInfo>|undefined;
private _factoryNames: string[]|undefined;
private _diagnosticProgram: ts.Program|undefined;
private _diagnosticsByFile: Map<string, Diagnostic[]>|undefined;
private _currentCancellationToken: ts.CancellationToken = stubCancellationToken;
private _partial: boolean = false;
constructor(
private program: ts.Program, private tsOptions: ts.CompilerOptions,
private compilerHost: ts.CompilerHost, private aotCompilerHost: AotCompilerHost,
private aotOptions: AotCompilerOptions, private _analyzedModules?: NgAnalyzedModules,
private _generatedFiles?: GeneratedFile[]) {}
getDiagnostics(fileName?: string, cancellationToken?: ts.CancellationToken): Diagnostic[] {
this._currentCancellationToken = cancellationToken || stubCancellationToken;
try {
return fileName ?
this.diagnosticsByFileName.get(fileName) || [] :
([] as Diagnostic[]).concat(...Array.from(this.diagnosticsByFileName.values()));
} finally {
this._currentCancellationToken = stubCancellationToken;
}
}
get partialResults(): boolean { return this._partial; }
private get analyzedModules(): NgAnalyzedModules {
return this._analyzedModules || (this._analyzedModules = this.aotCompiler.analyzeModulesSync(
this.program.getSourceFiles().map(sf => sf.fileName)));
}
private get diagnosticsByFileName(): Map<string, Diagnostic[]> {
return this._diagnosticsByFile || this.createDiagnosticsByFile();
}
private get diagnosticProgram(): ts.Program {
return this._diagnosticProgram || this.createDiagnosticProgram();
}
private get generatedFiles(): GeneratedFile[] {
let result = this._generatedFiles;
if (!result) {
this._generatedFiles = result = this.aotCompiler.emitAllImpls(this.analyzedModules);
}
return result;
}
private get aotCompiler(): AotCompiler {
return this._aotCompiler || this.createCompilerAndReflector();
}
private get reflector(): StaticReflector {
let result = this._reflector;
if (!result) {
this.createCompilerAndReflector();
result = this._reflector !;
}
return result;
}
private get factories(): Map<string, FactoryInfo> {
return this._factories || this.createFactories();
}
private get factoryNames(): string[] {
return this._factoryNames || (this.createFactories() && this._factoryNames !);
}
private createCompilerAndReflector() {
const {compiler, reflector} = createAotCompiler(this.aotCompilerHost, this.aotOptions);
this._reflector = reflector;
return this._aotCompiler = compiler;
}
private createDiagnosticProgram() {
// Create a program that is all the files from the original program plus the factories.
const existingFiles = this.program.getSourceFiles().map(source => source.fileName);
const host = new TypeCheckingHost(this.compilerHost, this.program, this.factories);
return this._diagnosticProgram =
ts.createProgram([...existingFiles, ...this.factoryNames], this.tsOptions, host);
}
private createFactories() {
// Create all the factory files with enough information to map the diagnostics reported for the
// created file back to the original source.
const emitter = new TypeScriptEmitter();
const factorySources =
this.generatedFiles.filter(file => file.stmts != null && file.stmts.length)
.map<[string, FactoryInfo]>(
file => [file.genFileUrl, createFactoryInfo(emitter, file)]);
this._factories = new Map(factorySources);
this._factoryNames = Array.from(this._factories.keys());
return this._factories;
}
private createDiagnosticsByFile() {
// Collect all the diagnostics binned by original source file name.
const result = new Map<string, Diagnostic[]>();
const diagnosticsFor = (fileName: string) => {
let r = result.get(fileName);
if (!r) {
r = [];
result.set(fileName, r);
}
return r;
};
const program = this.diagnosticProgram;
for (const factoryName of this.factoryNames) {
if (this._currentCancellationToken.isCancellationRequested()) return result;
const sourceFile = program.getSourceFile(factoryName);
for (const diagnostic of this.diagnosticProgram.getSemanticDiagnostics(sourceFile)) {
if (diagnostic.file && diagnostic.start) {
const span = this.sourceSpanOf(diagnostic.file, diagnostic.start);
if (span) {
const fileName = span.start.file.url;
const diagnosticsList = diagnosticsFor(fileName);
diagnosticsList.push({
messageText: diagnosticMessageToString(diagnostic.messageText),
category: diagnostic.category, span,
source: SOURCE,
code: DEFAULT_ERROR_CODE
});
}
}
}
}
return result;
}
private sourceSpanOf(source: ts.SourceFile, start: number): ParseSourceSpan|null {
// Find the corresponding TypeScript node
const info = this.factories.get(source.fileName);
if (info) {
const {line, character} = ts.getLineAndCharacterOfPosition(source, start);
return info.context.spanOf(line, character);
}
return null;
}
}
function diagnosticMessageToString(message: ts.DiagnosticMessageChain | string): string {
return ts.flattenDiagnosticMessageText(message, '\n');
}
const REWRITE_PREFIX = /^\u0275[0-9]+$/;
function createFactoryInfo(emitter: TypeScriptEmitter, file: GeneratedFile): FactoryInfo {
const {sourceText, context} = emitter.emitStatementsAndContext(
file.srcFileUrl, file.genFileUrl, file.stmts !,
/* preamble */ undefined, /* emitSourceMaps */ undefined,
/* referenceFilter */ reference => !!(reference.name && REWRITE_PREFIX.test(reference.name)));
const source = ts.createSourceFile(
file.genFileUrl, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true);
return {source, context};
}
class TypeCheckingHost implements ts.CompilerHost {
constructor(
private host: ts.CompilerHost, private originalProgram: ts.Program,
private factories: Map<string, FactoryInfo>) {}
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: ((message: string) => void)): ts.SourceFile {
const originalSource = this.originalProgram.getSourceFile(fileName);
if (originalSource) {
return originalSource;
}
const factoryInfo = this.factories.get(fileName);
if (factoryInfo) {
return factoryInfo.source;
}
return this.host.getSourceFile(fileName, languageVersion, onError);
}
getDefaultLibFileName(options: ts.CompilerOptions): string {
return this.host.getDefaultLibFileName(options);
}
writeFile: ts.WriteFileCallback =
() => { throw new Error('Unexpected write in diagnostic program'); };
getCurrentDirectory(): string { return this.host.getCurrentDirectory(); }
getDirectories(path: string): string[] { return this.host.getDirectories(path); }
getCanonicalFileName(fileName: string): string {
return this.host.getCanonicalFileName(fileName);
}
useCaseSensitiveFileNames(): boolean { return this.host.useCaseSensitiveFileNames(); }
getNewLine(): string { return this.host.getNewLine(); }
fileExists(fileName: string): boolean {
return this.factories.has(fileName) || this.host.fileExists(fileName);
}
readFile(fileName: string): string {
const factoryInfo = this.factories.get(fileName);
return (factoryInfo && factoryInfo.source.text) || this.host.readFile(fileName);
}
}

View File

@ -0,0 +1,57 @@
/**
* @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 {ParseSourceSpan} from '@angular/compiler';
import * as ts from 'typescript';
import {DEFAULT_ERROR_CODE, Diagnostic, SOURCE} from '../transformers/api';
import {GENERATED_FILES} from '../transformers/util';
export interface TypeCheckHost {
ngSpanOf(fileName: string, line: number, character: number): ParseSourceSpan|null;
}
export function translateDiagnostics(host: TypeCheckHost, untranslatedDiagnostics: ts.Diagnostic[]):
{ts: ts.Diagnostic[], ng: Diagnostic[]} {
const ts: ts.Diagnostic[] = [];
const ng: Diagnostic[] = [];
untranslatedDiagnostics.forEach((diagnostic) => {
if (diagnostic.file && diagnostic.start && GENERATED_FILES.test(diagnostic.file.fileName)) {
// We need to filter out diagnostics about unused functions as
// they are in fact referenced by nobody and only serve to surface
// type check errors.
if (diagnostic.code === /* ... is declared but never used */ 6133) {
return;
}
const span = sourceSpanOf(host, diagnostic.file, diagnostic.start);
if (span) {
const fileName = span.start.file.url;
ng.push({
messageText: diagnosticMessageToString(diagnostic.messageText),
category: diagnostic.category, span,
source: SOURCE,
code: DEFAULT_ERROR_CODE
});
return;
}
}
ts.push(diagnostic);
});
return {ts, ng};
}
function sourceSpanOf(host: TypeCheckHost, source: ts.SourceFile, start: number): ParseSourceSpan|
null {
const {line, character} = ts.getLineAndCharacterOfPosition(source, start);
return host.ngSpanOf(source.fileName, line, character);
}
function diagnosticMessageToString(message: ts.DiagnosticMessageChain | string): string {
return ts.flattenDiagnosticMessageText(message, '\n');
}

View File

@ -15,7 +15,6 @@ to the language service.
*/
export {CompilerHost, CompilerHostContext, MetadataProvider, ModuleResolutionHostAdapter, NodeCompilerHostContext} from './compiler_host';
export {TypeChecker} from './diagnostics/check_types';
export {DiagnosticTemplateInfo, ExpressionDiagnostic, getExpressionDiagnostics, getExpressionScope, getTemplateExpressionDiagnostics} from './diagnostics/expression_diagnostics';
export {AstType, DiagnosticKind, ExpressionDiagnosticsContext, TypeDiagnostic} from './diagnostics/expression_type';
export {BuiltinType, DeclarationKind, Definition, Location, PipeInfo, Pipes, Signature, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './diagnostics/symbols';

View File

@ -18,10 +18,6 @@ const TS_EXT = /\.ts$/;
export type Diagnostics = Array<ts.Diagnostic|api.Diagnostic>;
function isTsDiagnostic(diagnostic: any): diagnostic is ts.Diagnostic {
return diagnostic && diagnostic.source != 'angular';
}
export function formatDiagnostics(options: api.CompilerOptions, diags: Diagnostics): string {
if (diags && diags.length) {
const tsFormatHost: ts.FormatDiagnosticsHost = {
@ -31,7 +27,7 @@ export function formatDiagnostics(options: api.CompilerOptions, diags: Diagnosti
};
return diags
.map(d => {
if (isTsDiagnostic(d)) {
if (api.isTsDiagnostic(d)) {
return ts.formatDiagnostics([d], tsFormatHost);
} else {
let res = ts.DiagnosticCategory[d.category];

View File

@ -21,6 +21,14 @@ export interface Diagnostic {
source: 'angular';
}
export function isTsDiagnostic(diagnostic: any): diagnostic is ts.Diagnostic {
return diagnostic != null && diagnostic.source !== 'angular';
}
export function isNgDiagnostic(diagnostic: any): diagnostic is Diagnostic {
return diagnostic != null && diagnostic.source === 'angular';
}
export interface CompilerOptions extends ts.CompilerOptions {
// Absolute path to a directory where generated file structure is written.
// If unspecified, generated files will be written alongside sources.
@ -73,6 +81,10 @@ export interface CompilerOptions extends ts.CompilerOptions {
// Default is true.
generateCodeForLibraries?: boolean;
// Whether to enable all type checks for templates.
// This will be true be default in Angular 6.
fullTemplateTypeCheck?: boolean;
// Insert JSDoc type annotations needed by Closure Compiler
annotateForClosureCompiler?: boolean;

View File

@ -6,20 +6,19 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompiler, AotCompilerHost, AotCompilerOptions, GeneratedFile, MessageBundle, NgAnalyzedModules, Serializer, Xliff, Xliff2, Xmb, core, createAotCompiler, getParseErrors, isSyntaxError, toTypeScript} from '@angular/compiler';
import {AotCompiler, AotCompilerHost, AotCompilerOptions, EmitterVisitorContext, GeneratedFile, MessageBundle, NgAnalyzedModules, ParseSourceSpan, Serializer, TypeScriptEmitter, Xliff, Xliff2, Xmb, core, createAotCompiler, getParseErrors, isSyntaxError, toTypeScript} from '@angular/compiler';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {BaseAotCompilerHost} from '../compiler_host';
import {TypeChecker} from '../diagnostics/check_types';
import {TypeCheckHost, translateDiagnostics} from '../diagnostics/translate_diagnostics';
import {createBundleIndexHost} from '../metadata/index';
import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, EmitFlags, Program, SOURCE, TsEmitArguments, TsEmitCallback} from './api';
import {LowerMetadataCache, getExpressionLoweringTransformFactory} from './lower_expressions';
import {getAngularEmitterTransformFactory} from './node_emitter_transform';
const GENERATED_FILES = /(.*?)\.(ngfactory|shim\.ngstyle|ngstyle|ngsummary)\.(js|d\.ts|ts)$/;
import {GENERATED_FILES} from './util';
const emptyModules: NgAnalyzedModules = {
ngModules: [],
@ -45,12 +44,11 @@ class AngularCompilerProgram implements Program {
private _structuralDiagnostics: Diagnostic[] = [];
private _stubs: GeneratedFile[]|undefined;
private _stubFiles: string[]|undefined;
private _programWithStubsHost: ts.CompilerHost|undefined;
private _programWithStubsHost: ts.CompilerHost&TypeCheckHost|undefined;
private _programWithStubs: ts.Program|undefined;
private _generatedFiles: GeneratedFile[]|undefined;
private _generatedFileDiagnostics: Diagnostic[]|undefined;
private _typeChecker: TypeChecker|undefined;
private _semanticDiagnostics: Diagnostic[]|undefined;
private _semanticDiagnostics: {ts: ts.Diagnostic[], ng: Diagnostic[]}|undefined;
private _optionsDiagnostics: Diagnostic[] = [];
constructor(
@ -109,7 +107,7 @@ class AngularCompilerProgram implements Program {
getTsSemanticDiagnostics(sourceFile?: ts.SourceFile, cancellationToken?: ts.CancellationToken):
ts.Diagnostic[] {
return this.programWithStubs.getSemanticDiagnostics(sourceFile, cancellationToken);
return this.semanticDiagnostics.ts;
}
getNgSemanticDiagnostics(fileName?: string, cancellationToken?: ts.CancellationToken):
@ -119,8 +117,7 @@ class AngularCompilerProgram implements Program {
// If we have diagnostics during the parser phase the type check phase is not meaningful so skip
// it.
if (compilerDiagnostics && compilerDiagnostics.length) return compilerDiagnostics;
return this.typeChecker.getDiagnostics(fileName, cancellationToken);
return this.semanticDiagnostics.ng;
}
loadNgStructureAsync(): Promise<void> {
@ -187,7 +184,7 @@ class AngularCompilerProgram implements Program {
}, []));
}
private get programWithStubsHost(): ts.CompilerHost {
private get programWithStubsHost(): ts.CompilerHost&TypeCheckHost {
return this._programWithStubsHost || (this._programWithStubsHost = createProgramWithStubsHost(
this.stubs, this.tsProgram, this.host));
}
@ -200,16 +197,15 @@ class AngularCompilerProgram implements Program {
return this._generatedFiles || (this._generatedFiles = this.generateFiles());
}
private get typeChecker(): TypeChecker {
return (this._typeChecker && !this._typeChecker.partialResults) ?
this._typeChecker :
(this._typeChecker = this.createTypeChecker());
}
private get generatedFileDiagnostics(): Diagnostic[]|undefined {
return this.generatedFiles && this._generatedFileDiagnostics !;
}
private get semanticDiagnostics(): {ts: ts.Diagnostic[], ng: Diagnostic[]} {
return this._semanticDiagnostics ||
(this._semanticDiagnostics = this.generateSemanticDiagnostics());
}
private calculateTransforms(customTransformers?: CustomTransformers): ts.CustomTransformers {
const beforeTs: ts.TransformerFactory<ts.SourceFile>[] = [];
if (!this.options.disableExpressionLowering) {
@ -283,12 +279,6 @@ class AngularCompilerProgram implements Program {
}
}
private createTypeChecker(): TypeChecker {
return new TypeChecker(
this.tsProgram, this.options, this.host, this.aotCompilerHost, this.options,
this.analyzedModules, this.generatedFiles);
}
private createProgramWithStubs(): ts.Program {
// If we are skipping code generation just use the original program.
// Otherwise, create a new program that includes the stub files.
@ -297,6 +287,11 @@ class AngularCompilerProgram implements Program {
ts.createProgram(
[...this.rootNames, ...this.stubFiles], this.options, this.programWithStubsHost);
}
private generateSemanticDiagnostics(): {ts: ts.Diagnostic[], ng: Diagnostic[]} {
return translateDiagnostics(
this.programWithStubsHost, this.programWithStubs.getSemanticDiagnostics());
}
}
class AotCompilerHostImpl extends BaseAotCompilerHost<CompilerHost> {
@ -360,6 +355,7 @@ function getAotCompilerOptions(options: CompilerOptions): AotCompilerOptions {
enableLegacyTemplate: options.enableLegacyTemplate,
enableSummariesForJit: true,
preserveWhitespaces: options.preserveWhitespaces,
fullTemplateTypeCheck: options.fullTemplateTypeCheck,
};
}
@ -453,13 +449,15 @@ function getNgOptionDiagnostics(options: CompilerOptions): Diagnostic[] {
function createProgramWithStubsHost(
generatedFiles: GeneratedFile[], originalProgram: ts.Program,
originalHost: ts.CompilerHost): ts.CompilerHost {
originalHost: ts.CompilerHost): ts.CompilerHost&TypeCheckHost {
interface FileData {
g: GeneratedFile;
s?: ts.SourceFile;
emitCtx?: EmitterVisitorContext;
}
return new class implements ts.CompilerHost {
return new class implements ts.CompilerHost, TypeCheckHost {
private generatedFiles: Map<string, FileData>;
private emitter = new TypeScriptEmitter();
writeFile: ts.WriteFileCallback;
getCancellationToken: () => ts.CancellationToken;
getDefaultLibLocation: () => string;
@ -487,13 +485,27 @@ function createProgramWithStubsHost(
this.trace = s => originalHost.trace !(s);
}
}
ngSpanOf(fileName: string, line: number, character: number): ParseSourceSpan|null {
const data = this.generatedFiles.get(fileName);
if (data && data.emitCtx) {
return data.emitCtx.spanOf(line, character);
}
return null;
}
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: ((message: string) => void)|undefined): ts.SourceFile {
const data = this.generatedFiles.get(fileName);
if (data) {
return data.s || (data.s = ts.createSourceFile(
fileName, data.g.source || toTypeScript(data.g), languageVersion));
if (!data.s) {
const {sourceText, context} = this.emitter.emitStatementsAndContext(
data.g.srcFileUrl, data.g.genFileUrl, data.g.stmts !,
/* preamble */ undefined, /* emitSourceMaps */ undefined,
/* referenceFilter */ undefined);
data.emitCtx = context;
data.s = ts.createSourceFile(fileName, sourceText, languageVersion);
}
return data.s;
}
return originalProgram.getSourceFile(fileName) ||
originalHost.getSourceFile(fileName, languageVersion, onError);

View File

@ -0,0 +1,9 @@
/**
* @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 const GENERATED_FILES = /(.*?)\.(ngfactory|shim\.ngstyle|ngstyle|ngsummary)\.(js|d\.ts|ts)$/;

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));
}
}