When the compiler is invoked via ngc or the Angular CLI, its APIs are used under the assumption that Angular analysis/diagnostics are only requested if the program has no TypeScript-level errors. A result of this assumption is that the incremental engine has not needed to resolve changes via its dependency graph when the program contained broken imports, since broken imports are a TypeScript error. The Angular Language Service for Ivy is using the compiler as a backend, and exercising its incremental compilation APIs without enforcing this assumption. As a result, the Language Service has run into issues where broken imports cause incremental compilation to fail and produce incorrect results. This commit introduces a mechanism within the compiler to keep track of files for which dependency analysis has failed, and to always treat such files as potentially affected by future incremental steps. This is tested via the Language Service infrastructure to ensure that the compiler is doing the right thing in the case of invalid imports. PR Close #39923
240 lines
7.6 KiB
TypeScript
240 lines
7.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC 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 {TmplAstNode} from '@angular/compiler';
|
|
import {StrictTemplateOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
|
|
import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, getSourceFileOrError} from '@angular/compiler-cli/src/ngtsc/file_system';
|
|
import {MockFileSystem, TestFile} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
|
|
import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing';
|
|
import {TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
|
import * as ts from 'typescript/lib/tsserverlibrary';
|
|
|
|
import {LanguageService} from '../language_service';
|
|
|
|
import {MockServerHost} from './mock_host';
|
|
|
|
function writeTsconfig(
|
|
fs: FileSystem, entryFiles: AbsoluteFsPath[], options: TestableOptions): void {
|
|
fs.writeFile(
|
|
absoluteFrom('/tsconfig.json'),
|
|
|
|
JSON.stringify(
|
|
{
|
|
compilerOptions: {
|
|
strict: true,
|
|
experimentalDecorators: true,
|
|
moduleResolution: 'node',
|
|
target: 'es2015',
|
|
lib: [
|
|
'dom',
|
|
'es2015',
|
|
],
|
|
},
|
|
files: entryFiles,
|
|
angularCompilerOptions: {
|
|
strictTemplates: true,
|
|
...options,
|
|
}
|
|
},
|
|
null, 2));
|
|
}
|
|
|
|
export type TestableOptions = StrictTemplateOptions;
|
|
|
|
export interface TemplateOverwriteResult {
|
|
cursor: number;
|
|
nodes: TmplAstNode[];
|
|
component: ts.ClassDeclaration;
|
|
text: string;
|
|
}
|
|
|
|
export class LanguageServiceTestEnvironment {
|
|
private constructor(
|
|
readonly tsLS: ts.LanguageService, readonly ngLS: LanguageService,
|
|
readonly projectService: ts.server.ProjectService, readonly host: MockServerHost) {}
|
|
|
|
static setup(files: TestFile[], options: TestableOptions = {}): LanguageServiceTestEnvironment {
|
|
const fs = getFileSystem();
|
|
if (!(fs instanceof MockFileSystem)) {
|
|
throw new Error(`LanguageServiceTestEnvironment only works with a mock filesystem`);
|
|
}
|
|
fs.init(loadStandardTestFiles({
|
|
fakeCommon: true,
|
|
}));
|
|
|
|
const host = new MockServerHost(fs);
|
|
const tsconfigPath = absoluteFrom('/tsconfig.json');
|
|
|
|
const entryFiles: AbsoluteFsPath[] = [];
|
|
for (const {name, contents, isRoot} of files) {
|
|
fs.writeFile(name, contents);
|
|
if (isRoot === true) {
|
|
entryFiles.push(name);
|
|
}
|
|
}
|
|
|
|
if (entryFiles.length === 0) {
|
|
throw new Error(`Expected at least one root TestFile.`);
|
|
}
|
|
|
|
writeTsconfig(fs, files.filter(file => file.isRoot === true).map(file => file.name), options);
|
|
|
|
const projectService = new ts.server.ProjectService({
|
|
host,
|
|
logger,
|
|
cancellationToken: ts.server.nullCancellationToken,
|
|
useSingleInferredProject: true,
|
|
useInferredProjectPerProjectRoot: true,
|
|
typingsInstaller: ts.server.nullTypingsInstaller,
|
|
});
|
|
|
|
// Open all root files.
|
|
for (const entryFile of entryFiles) {
|
|
projectService.openClientFile(entryFile);
|
|
}
|
|
|
|
const project = projectService.findProject(tsconfigPath);
|
|
if (project === undefined) {
|
|
throw new Error(`Failed to create project for ${tsconfigPath}`);
|
|
}
|
|
// The following operation forces a ts.Program to be created.
|
|
const tsLS = project.getLanguageService();
|
|
|
|
const ngLS = new LanguageService(project, tsLS);
|
|
return new LanguageServiceTestEnvironment(tsLS, ngLS, projectService, host);
|
|
}
|
|
|
|
getClass(fileName: AbsoluteFsPath, className: string): ts.ClassDeclaration {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
const sf = getSourceFileOrError(program, fileName);
|
|
return getClassOrError(sf, className);
|
|
}
|
|
|
|
overrideTemplateWithCursor(fileName: AbsoluteFsPath, className: string, contents: string):
|
|
TemplateOverwriteResult {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
const sf = getSourceFileOrError(program, fileName);
|
|
const component = getClassOrError(sf, className);
|
|
|
|
const ngCompiler = this.ngLS.compilerFactory.getOrCreate();
|
|
const templateTypeChecker = ngCompiler.getTemplateTypeChecker();
|
|
|
|
const {cursor, text} = extractCursorInfo(contents);
|
|
|
|
const {nodes} = templateTypeChecker.overrideComponentTemplate(component, text);
|
|
return {cursor, nodes, component, text};
|
|
}
|
|
|
|
updateFile(fileName: AbsoluteFsPath, contents: string): void {
|
|
const scriptInfo = this.projectService.getScriptInfo(fileName);
|
|
if (scriptInfo === undefined) {
|
|
throw new Error(`Could not find a file named ${fileName}`);
|
|
}
|
|
|
|
// Get the current contents to find the length
|
|
const len = scriptInfo.getSnapshot().getLength();
|
|
scriptInfo.editContent(0, len, contents);
|
|
}
|
|
|
|
expectNoSourceDiagnostics(): void {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
|
|
const ngCompiler = this.ngLS.compilerFactory.getOrCreate();
|
|
|
|
for (const sf of program.getSourceFiles()) {
|
|
if (sf.isDeclarationFile || sf.fileName.endsWith('.ngtypecheck.ts')) {
|
|
continue;
|
|
}
|
|
|
|
const syntactic = program.getSyntacticDiagnostics(sf);
|
|
expect(syntactic.map(diag => diag.messageText)).toEqual([]);
|
|
if (syntactic.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
const semantic = program.getSemanticDiagnostics(sf);
|
|
expect(semantic.map(diag => diag.messageText)).toEqual([]);
|
|
if (semantic.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
const ngDiagnostics = ngCompiler.getDiagnostics(sf);
|
|
expect(ngDiagnostics.map(diag => diag.messageText)).toEqual([]);
|
|
}
|
|
|
|
this.ngLS.compilerFactory.registerLastKnownProgram();
|
|
}
|
|
|
|
expectNoTemplateDiagnostics(fileName: AbsoluteFsPath, className: string): void {
|
|
const program = this.tsLS.getProgram();
|
|
if (program === undefined) {
|
|
throw new Error(`Expected to get a ts.Program`);
|
|
}
|
|
const sf = getSourceFileOrError(program, fileName);
|
|
const component = getClassOrError(sf, className);
|
|
|
|
const diags = this.getTemplateTypeChecker().getDiagnosticsForComponent(component);
|
|
this.ngLS.compilerFactory.registerLastKnownProgram();
|
|
expect(diags.map(diag => diag.messageText)).toEqual([]);
|
|
}
|
|
|
|
getTemplateTypeChecker(): TemplateTypeChecker {
|
|
return this.ngLS.compilerFactory.getOrCreate().getTemplateTypeChecker();
|
|
}
|
|
}
|
|
|
|
const logger: ts.server.Logger = {
|
|
close(): void{},
|
|
hasLevel(level: ts.server.LogLevel): boolean {
|
|
return false;
|
|
},
|
|
loggingEnabled(): boolean {
|
|
return false;
|
|
},
|
|
perftrc(s: string): void{},
|
|
info(s: string): void{},
|
|
startGroup(): void{},
|
|
endGroup(): void{},
|
|
msg(s: string, type?: ts.server.Msg): void{},
|
|
getLogFileName(): string |
|
|
undefined {
|
|
return;
|
|
},
|
|
};
|
|
|
|
|
|
function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration {
|
|
for (const stmt of sf.statements) {
|
|
if (ts.isClassDeclaration(stmt) && stmt.name !== undefined && stmt.name.text === name) {
|
|
return stmt;
|
|
}
|
|
}
|
|
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
|
|
}
|
|
|
|
export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
|
|
const cursor = textWithCursor.indexOf('¦');
|
|
if (cursor === -1) {
|
|
throw new Error(`Expected to find cursor symbol '¦'`);
|
|
}
|
|
|
|
return {
|
|
cursor,
|
|
text: textWithCursor.substr(0, cursor) + textWithCursor.substr(cursor + 1),
|
|
};
|
|
}
|