refactor(compiler-cli): move the expression expression type checker (#16562)

The expression type checker moved from the language service
to the compiler-cli in preparation to using it to check
template expressions.
This commit is contained in:
Chuck Jazdzewski
2017-05-09 16:16:50 -07:00
committed by Jason Aden
parent 9e661e58d1
commit bb0902c592
23 changed files with 2891 additions and 2270 deletions

View File

@ -0,0 +1,230 @@
/**
* @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 {StaticSymbol} from '@angular/compiler';
import {AngularCompilerOptions, CompilerHost} from '@angular/compiler-cli';
import * as ts from 'typescript';
import {getExpressionDiagnostics, getTemplateExpressionDiagnostics} from '../../src/diagnostics/expression_diagnostics';
import {Directory} from '../mocks';
import {DiagnosticContext, MockLanguageServiceHost, getDiagnosticTemplateInfo} from './mocks';
describe('expression diagnostics', () => {
let registry: ts.DocumentRegistry;
let host: MockLanguageServiceHost;
let compilerHost: CompilerHost;
let service: ts.LanguageService;
let context: DiagnosticContext;
let aotHost: CompilerHost;
let type: StaticSymbol;
beforeAll(() => {
registry = ts.createDocumentRegistry(false, '/src');
host = new MockLanguageServiceHost(['app/app.component.ts'], FILES, '/src');
service = ts.createLanguageService(host, registry);
const program = service.getProgram();
const checker = program.getTypeChecker();
const options: AngularCompilerOptions = Object.create(host.getCompilationSettings());
options.genDir = '/dist';
options.basePath = '/src';
aotHost = new CompilerHost(program, options, host, {verboseInvalidExpression: true});
context = new DiagnosticContext(service, program, checker, aotHost);
type = context.getStaticSymbol('app/app.component.ts', 'AppComponent');
});
it('should have no diagnostics in default app', () => {
function messageToString(messageText: string | ts.DiagnosticMessageChain): string {
if (typeof messageText == 'string') {
return messageText;
} else {
if (messageText.next) return messageText.messageText + messageToString(messageText.next);
return messageText.messageText;
}
}
function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) {
if (diagnostics && diagnostics.length) {
const message =
'messags: ' + diagnostics.map(d => messageToString(d.messageText)).join('\n');
expect(message).toEqual('');
}
}
expectNoDiagnostics(service.getCompilerOptionsDiagnostics());
expectNoDiagnostics(service.getSyntacticDiagnostics('app/app.component.ts'));
expectNoDiagnostics(service.getSemanticDiagnostics('app/app.component.ts'));
});
function accept(template: string) {
const info = getDiagnosticTemplateInfo(context, type, 'app/app.component.html', template);
if (info) {
const diagnostics = getTemplateExpressionDiagnostics(info);
if (diagnostics && diagnostics.length) {
const message = diagnostics.map(d => d.message).join('\n ');
throw new Error(`Unexpected diagnostics: ${message}`);
}
} else {
expect(info).toBeDefined();
}
}
function reject(template: string, expected: string | RegExp) {
const info = getDiagnosticTemplateInfo(context, type, 'app/app.component.html', template);
if (info) {
const diagnostics = getTemplateExpressionDiagnostics(info);
if (diagnostics && diagnostics.length) {
const messages = diagnostics.map(d => d.message).join('\n ');
expect(messages).toContain(expected);
} else {
throw new Error(`Expected an error containing "${expected} in template "${template}"`);
}
} else {
expect(info).toBeDefined();
}
}
it('should accept a simple template', () => accept('App works!'));
it('should accept an interpolation', () => accept('App works: {{person.name.first}}'));
it('should reject misspelled access',
() => reject('{{persson}}', 'Identifier \'persson\' is not defined'));
it('should reject access to private',
() =>
reject('{{private_person}}', 'Identifier \'private_person\' refers to a private member'));
it('should accept an *ngIf', () => accept('<div *ngIf="person">{{person.name.first}}</div>'));
it('should reject *ngIf of misspelled identifier',
() => reject(
'<div *ngIf="persson">{{person.name.first}}</div>',
'Identifier \'persson\' is not defined'));
it('should accept an *ngFor', () => accept(`
<div *ngFor="let p of people">
{{p.name.first}} {{p.name.last}}
</div>
`));
it('should reject misspelled field in *ngFor', () => reject(
`
<div *ngFor="let p of people">
{{p.names.first}} {{p.name.last}}
</div>
`,
'Identifier \'names\' is not defined'));
it('should accept an async expression',
() => accept('{{(promised_person | async)?.name.first || ""}}'));
it('should reject an async misspelled field',
() => reject(
'{{(promised_person | async)?.nume.first || ""}}', 'Identifier \'nume\' is not defined'));
it('should accept an async *ngFor', () => accept(`
<div *ngFor="let p of promised_people | async">
{{p.name.first}} {{p.name.last}}
</div>
`));
it('should reject misspelled field an async *ngFor', () => reject(
`
<div *ngFor="let p of promised_people | async">
{{p.name.first}} {{p.nume.last}}
</div>
`,
'Identifier \'nume\' is not defined'));
it('should reject access to potentially undefined field',
() => reject(`<div>{{maybe_person.name.first}}`, 'The expression might be null'));
it('should accept a safe accss to an undefined field',
() => accept(`<div>{{maybe_person?.name.first}}</div>`));
it('should accept a # reference', () => accept(`
<form #f="ngForm" novalidate>
<input name="first" ngModel required #first="ngModel">
<input name="last" ngModel>
<button>Submit</button>
</form>
<p>First name value: {{ first.value }}</p>
<p>First name valid: {{ first.valid }}</p>
<p>Form value: {{ f.value | json }}</p>
<p>Form valid: {{ f.valid }}</p>
`));
it('should reject a misspelled field of a # reference',
() => reject(
`
<form #f="ngForm" novalidate>
<input name="first" ngModel required #first="ngModel">
<input name="last" ngModel>
<button>Submit</button>
</form>
<p>First name value: {{ first.valwe }}</p>
<p>First name valid: {{ first.valid }}</p>
<p>Form value: {{ f.value | json }}</p>
<p>Form valid: {{ f.valid }}</p>
`,
'Identifier \'valwe\' is not defined'));
it('should accept a call to a method', () => accept('{{getPerson().name.first}}'));
it('should reject a misspelled field of a method result',
() => reject('{{getPerson().nume.first}}', 'Identifier \'nume\' is not defined'));
it('should reject calling a uncallable member',
() => reject('{{person().name.first}}', 'Member \'person\' is not callable'));
it('should accept an event handler',
() => accept('<div (click)="click($event)">{{person.name.first}}</div>'));
it('should reject a misspelled event handler',
() => reject(
'<div (click)="clack($event)">{{person.name.first}}</div>', 'Unknown method \'clack\''));
it('should reject an uncalled event handler',
() => reject(
'<div (click)="click">{{person.name.first}}</div>', 'Unexpected callable expression'));
});
const FILES: Directory = {
'src': {
'app': {
'app.component.ts': `
import { Component, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface Person {
name: Name;
address: Address;
}
export interface Name {
first: string;
middle: string;
last: string;
}
export interface Address {
street: string;
city: string;
state: string;
zip: string;
}
@Component({
selector: 'my-app',
templateUrl: './app.component.html'
})
export class AppComponent {
person: Person;
people: Person[];
maybe_person?: Person;
promised_person: Promise<Person>;
promised_people: Promise<Person[]>;
private private_person: Person;
private private_people: Person[];
getPerson(): Person { return this.person; }
click() {}
}
@NgModule({
imports: [CommonModule, FormsModule],
declarations: [AppComponent]
})
export class AppModule {}
`
}
}
};

View File

@ -0,0 +1,255 @@
/**
* @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 {AotCompilerHost, AotSummaryResolver, CompileMetadataResolver, CompilerConfig, DEFAULT_INTERPOLATION_CONFIG, DirectiveNormalizer, DirectiveResolver, DomElementSchemaRegistry, HtmlParser, I18NHtmlParser, InterpolationConfig, JitSummaryResolver, Lexer, NgAnalyzedModules, NgModuleResolver, ParseTreeResult, Parser, PipeResolver, ResourceLoader, StaticAndDynamicReflectionCapabilities, StaticReflector, StaticSymbol, StaticSymbolCache, StaticSymbolResolver, SummaryResolver, TemplateParser, analyzeNgModules, createOfflineCompileUrlResolver, extractProgramSymbols} from '@angular/compiler';
import {ViewEncapsulation, ɵConsole as Console} from '@angular/core';
import {CompilerHostContext} from 'compiler-cli';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {DiagnosticTemplateInfo} from '../../src/diagnostics/expression_diagnostics';
import {getClassFromStaticSymbol, getClassMembers, getPipesTable, getSymbolQuery} from '../../src/diagnostics/typescript_symbols';
import {Directory, MockAotContext} from '../mocks';
const packages = path.join(__dirname, '../../../../../packages');
const realFiles = new Map<string, string>();
export class MockLanguageServiceHost implements ts.LanguageServiceHost, CompilerHostContext {
private options: ts.CompilerOptions;
private context: MockAotContext;
private assumedExist = new Set<string>();
constructor(private scripts: string[], files: Directory, currentDirectory: string = '/') {
this.options = {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
emitDecoratorMetadata: true,
experimentalDecorators: true,
removeComments: false,
noImplicitAny: false,
skipLibCheck: true,
skipDefaultLibCheck: true,
strictNullChecks: true,
baseUrl: currentDirectory,
lib: ['lib.es2015.d.ts', 'lib.dom.d.ts'],
paths: {'@angular/*': [packages + '/*']}
};
this.context = new MockAotContext(currentDirectory, files)
}
getCompilationSettings(): ts.CompilerOptions { return this.options; }
getScriptFileNames(): string[] { return this.scripts; }
getScriptVersion(fileName: string): string { return '0'; }
getScriptSnapshot(fileName: string): ts.IScriptSnapshot|undefined {
const content = this.internalReadFile(fileName);
if (content) {
return ts.ScriptSnapshot.fromString(content);
}
}
getCurrentDirectory(): string { return this.context.currentDirectory; }
getDefaultLibFileName(options: ts.CompilerOptions): string { return 'lib.d.ts'; }
readFile(fileName: string): string { return this.internalReadFile(fileName) as string; }
readResource(fileName: string): Promise<string> { return Promise.resolve(''); }
assumeFileExists(fileName: string): void { this.assumedExist.add(fileName); }
fileExists(fileName: string): boolean {
return this.assumedExist.has(fileName) || this.internalReadFile(fileName) != null;
}
private internalReadFile(fileName: string): string|undefined {
let basename = path.basename(fileName);
if (/^lib.*\.d\.ts$/.test(basename)) {
let libPath = path.dirname(ts.getDefaultLibFilePath(this.getCompilationSettings()));
fileName = path.join(libPath, basename);
}
if (fileName.startsWith('app/')) {
fileName = path.join(this.context.currentDirectory, fileName);
}
if (this.context.fileExists(fileName)) {
return this.context.readFile(fileName);
}
if (realFiles.has(fileName)) {
return realFiles.get(fileName);
}
if (fs.existsSync(fileName)) {
const content = fs.readFileSync(fileName, 'utf8');
realFiles.set(fileName, content);
return content;
}
return undefined;
}
}
const staticSymbolCache = new StaticSymbolCache();
const summaryResolver = new AotSummaryResolver(
{
loadSummary(filePath: string) { return null; },
isSourceFile(sourceFilePath: string) { return true; },
getOutputFileName(sourceFilePath: string) { return sourceFilePath; }
},
staticSymbolCache);
export class DiagnosticContext {
_analyzedModules: NgAnalyzedModules;
_staticSymbolResolver: StaticSymbolResolver|undefined;
_reflector: StaticReflector|undefined;
_errors: {e: any, path?: string}[] = [];
_resolver: CompileMetadataResolver|undefined;
_refletor: StaticReflector;
constructor(
public service: ts.LanguageService, public program: ts.Program,
public checker: ts.TypeChecker, public host: AotCompilerHost) {}
private collectError(e: any, path?: string) { this._errors.push({e, path}); }
private get staticSymbolResolver(): StaticSymbolResolver {
let result = this._staticSymbolResolver;
if (!result) {
result = this._staticSymbolResolver = new StaticSymbolResolver(
this.host, staticSymbolCache, summaryResolver,
(e, filePath) => this.collectError(e, filePath));
}
return result;
}
get reflector(): StaticReflector {
if (!this._reflector) {
const ssr = this.staticSymbolResolver;
const result = this._reflector = new StaticReflector(
summaryResolver, ssr, [], [], (e, filePath) => this.collectError(e, filePath !));
StaticAndDynamicReflectionCapabilities.install(result);
this._reflector = result;
return result;
}
return this._reflector;
}
get resolver(): CompileMetadataResolver {
let result = this._resolver;
if (!result) {
const moduleResolver = new NgModuleResolver(this.reflector);
const directiveResolver = new DirectiveResolver(this.reflector);
const pipeResolver = new PipeResolver(this.reflector);
const elementSchemaRegistry = new DomElementSchemaRegistry();
const resourceLoader = new class extends ResourceLoader {
get(url: string): Promise<string> { return Promise.resolve(''); }
};
const urlResolver = createOfflineCompileUrlResolver();
const htmlParser = new class extends HtmlParser {
parse(
source: string, url: string, parseExpansionForms: boolean = false,
interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG):
ParseTreeResult {
return new ParseTreeResult([], []);
}
};
// This tracks the CompileConfig in codegen.ts. Currently these options
// are hard-coded.
const config =
new CompilerConfig({defaultEncapsulation: ViewEncapsulation.Emulated, useJit: false});
const directiveNormalizer =
new DirectiveNormalizer(resourceLoader, urlResolver, htmlParser, config);
result = this._resolver = new CompileMetadataResolver(
config, moduleResolver, directiveResolver, pipeResolver, new JitSummaryResolver(),
elementSchemaRegistry, directiveNormalizer, new Console(), staticSymbolCache,
this.reflector, (error, type) => this.collectError(error, type && type.filePath));
}
return result;
}
get analyzedModules(): NgAnalyzedModules {
let analyzedModules = this._analyzedModules;
if (!analyzedModules) {
const analyzeHost = {isSourceFile(filePath: string) { return true; }};
const programSymbols = extractProgramSymbols(
this.staticSymbolResolver, this.program.getSourceFiles().map(sf => sf.fileName),
analyzeHost);
analyzedModules = this._analyzedModules =
analyzeNgModules(programSymbols, analyzeHost, this.resolver);
}
return analyzedModules;
}
getStaticSymbol(path: string, name: string): StaticSymbol {
return staticSymbolCache.get(path, name);
}
}
function compileTemplate(context: DiagnosticContext, type: StaticSymbol, template: string) {
// Compiler the template string.
const resolvedMetadata = context.resolver.getNonNormalizedDirectiveMetadata(type);
const metadata = resolvedMetadata && resolvedMetadata.metadata;
if (metadata) {
const rawHtmlParser = new HtmlParser();
const htmlParser = new I18NHtmlParser(rawHtmlParser);
const expressionParser = new Parser(new Lexer());
const config = new CompilerConfig();
const parser = new TemplateParser(
config, expressionParser, new DomElementSchemaRegistry(), htmlParser, null !, []);
const htmlResult = htmlParser.parse(template, '', true);
const analyzedModules = context.analyzedModules;
// let errors: Diagnostic[]|undefined = undefined;
let ngModule = analyzedModules.ngModuleByPipeOrDirective.get(type);
if (ngModule) {
const resolvedDirectives = ngModule.transitiveModule.directives.map(
d => context.resolver.getNonNormalizedDirectiveMetadata(d.reference));
const directives = removeMissing(resolvedDirectives).map(d => d.metadata.toSummary());
const pipes = ngModule.transitiveModule.pipes.map(
p => context.resolver.getOrLoadPipeMetadata(p.reference).toSummary());
const schemas = ngModule.schemas;
const parseResult = parser.tryParseHtml(htmlResult, metadata, directives, pipes, schemas);
return {
htmlAst: htmlResult.rootNodes,
templateAst: parseResult.templateAst,
directive: metadata, directives, pipes,
parseErrors: parseResult.errors, expressionParser
};
}
}
}
export function getDiagnosticTemplateInfo(
context: DiagnosticContext, type: StaticSymbol, templateFile: string,
template: string): DiagnosticTemplateInfo|undefined {
const compiledTemplate = compileTemplate(context, type, template);
if (compiledTemplate && compiledTemplate.templateAst) {
const members = getClassMembers(context.program, context.checker, type);
if (members) {
const sourceFile = context.program.getSourceFile(type.filePath);
const query = getSymbolQuery(
context.program, context.checker, sourceFile,
() =>
getPipesTable(sourceFile, context.program, context.checker, compiledTemplate.pipes));
return {
fileName: templateFile,
offset: 0, query, members,
htmlAst: compiledTemplate.htmlAst,
templateAst: compiledTemplate.templateAst
};
}
}
}
function removeMissing<T>(values: (T | null | undefined)[]): T[] {
return values.filter(e => !!e) as T[];
}