feat(language-service): [Ivy] getSemanticDiagnostics for external templates (#39065)

This PR enables `getSemanticDiagnostics()` to be called on external templates.

Several changes are needed to land this feature:

1. The adapter needs to implement two additional methods:
   a. `readResource()`
       Load the template from snapshot instead of reading from disk
   b. `getModifiedResourceFiles()`
       Inform the compiler that external templates have changed so that the
       loader could invalidate its internal cache.
2. Create `ScriptInfo` for external templates in MockHost.
   Prior to this, MockHost only track changes in TypeScript files. Now it
   needs to create `ScriptInfo` for external templates as well.

For (1), in order to make sure we don't reload the template if it hasn't
changed, we need to keep track of its version. Since the complexity has
increased, the adapter is refactored into its own class.

PR Close #39065
This commit is contained in:
Keen Yee Liau 2020-09-30 10:59:38 -07:00 committed by atscott
parent 4604fe9ed2
commit 63624a2d46
5 changed files with 241 additions and 46 deletions

View File

@ -8,60 +8,74 @@
import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli'; import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {NgCompilerAdapter} from '@angular/compiler-cli/src/ngtsc/core/api'; import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {PatchedProgramIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental'; import {PatchedProgramIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental';
import {isShim} from '@angular/compiler-cli/src/ngtsc/shims';
import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck';
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript/lib/tsserverlibrary'; import * as ts from 'typescript/lib/tsserverlibrary';
import {DefinitionBuilder} from './definitions'; import {DefinitionBuilder} from './definitions';
import {isExternalTemplate, isTypeScriptFile, LanguageServiceAdapter} from './language_service_adapter';
import {QuickInfoBuilder} from './quick_info'; import {QuickInfoBuilder} from './quick_info';
export class LanguageService { export class LanguageService {
private options: CompilerOptions; private options: CompilerOptions;
private lastKnownProgram: ts.Program|null = null; private lastKnownProgram: ts.Program|null = null;
private readonly strategy: TypeCheckingProgramStrategy; private readonly strategy: TypeCheckingProgramStrategy;
private readonly adapter: NgCompilerAdapter; private readonly adapter: LanguageServiceAdapter;
constructor(project: ts.server.Project, private readonly tsLS: ts.LanguageService) { constructor(project: ts.server.Project, private readonly tsLS: ts.LanguageService) {
this.options = parseNgCompilerOptions(project); this.options = parseNgCompilerOptions(project);
this.strategy = createTypeCheckingProgramStrategy(project); this.strategy = createTypeCheckingProgramStrategy(project);
this.adapter = createNgCompilerAdapter(project); this.adapter = new LanguageServiceAdapter(project);
this.watchConfigFile(project); this.watchConfigFile(project);
} }
getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { getSemanticDiagnostics(fileName: string): ts.Diagnostic[] {
const program = this.strategy.getProgram(); const program = this.strategy.getProgram();
const compiler = this.createCompiler(program); const compiler = this.createCompiler(program, fileName);
if (fileName.endsWith('.ts')) { const ttc = compiler.getTemplateTypeChecker();
const diagnostics: ts.Diagnostic[] = [];
if (isTypeScriptFile(fileName)) {
const sourceFile = program.getSourceFile(fileName); const sourceFile = program.getSourceFile(fileName);
if (!sourceFile) { if (sourceFile) {
return []; diagnostics.push(...ttc.getDiagnosticsForFile(sourceFile, OptimizeFor.SingleFile));
}
} else {
const components = compiler.getComponentsWithTemplateFile(fileName);
for (const component of components) {
if (ts.isClassDeclaration(component)) {
diagnostics.push(...ttc.getDiagnosticsForComponent(component));
}
} }
const ttc = compiler.getTemplateTypeChecker();
const diagnostics = ttc.getDiagnosticsForFile(sourceFile, OptimizeFor.SingleFile);
this.lastKnownProgram = compiler.getNextProgram();
return diagnostics;
} }
throw new Error('Ivy LS currently does not support external template'); this.lastKnownProgram = compiler.getNextProgram();
return diagnostics;
} }
getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan
|undefined { |undefined {
const program = this.strategy.getProgram(); const program = this.strategy.getProgram();
const compiler = this.createCompiler(program); const compiler = this.createCompiler(program, fileName);
return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position); return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position);
} }
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
const program = this.strategy.getProgram(); const program = this.strategy.getProgram();
const compiler = this.createCompiler(program); const compiler = this.createCompiler(program, fileName);
return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position); return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position);
} }
private createCompiler(program: ts.Program): NgCompiler { /**
* Create a new instance of Ivy compiler.
* If the specified `fileName` refers to an external template, check if it has
* changed since the last time it was read. If it has changed, signal the
* compiler to reload the file via the adapter.
*/
private createCompiler(program: ts.Program, fileName: string): NgCompiler {
if (isExternalTemplate(fileName)) {
this.adapter.registerTemplateUpdate(fileName);
}
return new NgCompiler( return new NgCompiler(
this.adapter, this.adapter,
this.options, this.options,
@ -107,31 +121,6 @@ export function parseNgCompilerOptions(project: ts.server.Project): CompilerOpti
return createNgCompilerOptions(basePath, config, project.getCompilationSettings()); return createNgCompilerOptions(basePath, config, project.getCompilationSettings());
} }
function createNgCompilerAdapter(project: ts.server.Project): NgCompilerAdapter {
return {
entryPoint: null, // entry point is only needed if code is emitted
constructionDiagnostics: [],
ignoreForEmit: new Set(),
factoryTracker: null, // no .ngfactory shims
unifiedModulesHost: null, // only used in Bazel
rootDirs: project.getCompilationSettings().rootDirs?.map(absoluteFrom) || [],
isShim,
fileExists(fileName: string): boolean {
return project.fileExists(fileName);
},
readFile(fileName: string): string |
undefined {
return project.readFile(fileName);
},
getCurrentDirectory(): string {
return project.getCurrentDirectory();
},
getCanonicalFileName(fileName: string): string {
return project.projectService.toCanonicalFileName(fileName);
},
};
}
function createTypeCheckingProgramStrategy(project: ts.server.Project): function createTypeCheckingProgramStrategy(project: ts.server.Project):
TypeCheckingProgramStrategy { TypeCheckingProgramStrategy {
return { return {

View File

@ -0,0 +1,111 @@
/**
* @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 {NgCompilerAdapter} from '@angular/compiler-cli/src/ngtsc/core/api';
import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {isShim} from '@angular/compiler-cli/src/ngtsc/shims';
import * as ts from 'typescript/lib/tsserverlibrary';
export class LanguageServiceAdapter implements NgCompilerAdapter {
readonly entryPoint = null;
readonly constructionDiagnostics: ts.Diagnostic[] = [];
readonly ignoreForEmit: Set<ts.SourceFile> = new Set();
readonly factoryTracker = null; // no .ngfactory shims
readonly unifiedModulesHost = null; // only used in Bazel
readonly rootDirs: AbsoluteFsPath[];
private readonly templateVersion = new Map<string, string>();
private readonly modifiedTemplates = new Set<string>();
constructor(private readonly project: ts.server.Project) {
this.rootDirs = project.getCompilationSettings().rootDirs?.map(absoluteFrom) || [];
}
isShim(sf: ts.SourceFile): boolean {
return isShim(sf);
}
fileExists(fileName: string): boolean {
return this.project.fileExists(fileName);
}
readFile(fileName: string): string|undefined {
return this.project.readFile(fileName);
}
getCurrentDirectory(): string {
return this.project.getCurrentDirectory();
}
getCanonicalFileName(fileName: string): string {
return this.project.projectService.toCanonicalFileName(fileName);
}
/**
* readResource() is an Angular-specific method for reading files that are not
* managed by the TS compiler host, namely templates and stylesheets.
* It is a method on ExtendedTsCompilerHost, see
* packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts
*/
readResource(fileName: string): string {
if (isTypeScriptFile(fileName)) {
throw new Error(`readResource() should not be called on TS file: ${fileName}`);
}
// Calling getScriptSnapshot() will actually create a ScriptInfo if it does
// not exist! The same applies for getScriptVersion().
// getScriptInfo() will not create one if it does not exist.
// In this case, we *want* a script info to be created so that we could
// keep track of its version.
const snapshot = this.project.getScriptSnapshot(fileName);
if (!snapshot) {
// This would fail if the file does not exist, or readFile() fails for
// whatever reasons.
throw new Error(`Failed to get script snapshot while trying to read ${fileName}`);
}
const version = this.project.getScriptVersion(fileName);
this.templateVersion.set(fileName, version);
this.modifiedTemplates.delete(fileName);
return snapshot.getText(0, snapshot.getLength());
}
/**
* getModifiedResourceFiles() is an Angular-specific method for notifying
* the Angular compiler templates that have changed since it last read them.
* It is a method on ExtendedTsCompilerHost, see
* packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts
*/
getModifiedResourceFiles(): Set<string> {
return this.modifiedTemplates;
}
/**
* Check whether the specified `fileName` is newer than the last time it was
* read. If it is newer, register it and return true, otherwise do nothing and
* return false.
* @param fileName path to external template
*/
registerTemplateUpdate(fileName: string): boolean {
if (!isExternalTemplate(fileName)) {
return false;
}
const lastVersion = this.templateVersion.get(fileName);
const latestVersion = this.project.getScriptVersion(fileName);
if (lastVersion !== latestVersion) {
this.modifiedTemplates.add(fileName);
return true;
}
return false;
}
}
export function isTypeScriptFile(fileName: string): boolean {
return fileName.endsWith('.ts');
}
export function isExternalTemplate(fileName: string): boolean {
return !isTypeScriptFile(fileName);
}

View File

@ -10,9 +10,9 @@ import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from '../language_service'; import {LanguageService} from '../language_service';
import {APP_COMPONENT, setup} from './mock_host'; import {APP_COMPONENT, setup, TEST_TEMPLATE} from './mock_host';
describe('diagnostic', () => { describe('getSemanticDiagnostics', () => {
const {project, service, tsLS} = setup(); const {project, service, tsLS} = setup();
const ngLS = new LanguageService(project, tsLS); const ngLS = new LanguageService(project, tsLS);
@ -35,4 +35,35 @@ describe('diagnostic', () => {
expect(text.substring(start!, start! + length!)).toBe('nope'); expect(text.substring(start!, start! + length!)).toBe('nope');
expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`); expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`);
}); });
it('should process external template', () => {
const diags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(diags).toEqual([]);
});
it('should report member does not exist in external template', () => {
const {text} = service.overwrite(TEST_TEMPLATE, `{{ nope }}`);
const diags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(diags.length).toBe(1);
const {category, file, start, length, messageText} = diags[0];
expect(category).toBe(ts.DiagnosticCategory.Error);
expect(file?.fileName).toBe(TEST_TEMPLATE);
expect(text.substring(start!, start! + length!)).toBe('nope');
expect(messageText).toBe(`Property 'nope' does not exist on type 'TemplateReference'.`);
});
it('should retrieve external template from latest snapshot', () => {
// This test is to make sure we are reading from snapshot instead of disk
// if content from snapshot is newer. It also makes sure the internal cache
// of the resource loader is invalidated on content change.
service.overwrite(TEST_TEMPLATE, `{{ foo }}`);
const d1 = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(d1.length).toBe(1);
expect(d1[0].messageText).toBe(`Property 'foo' does not exist on type 'TemplateReference'.`);
service.overwrite(TEST_TEMPLATE, `{{ bar }}`);
const d2 = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(d2.length).toBe(1);
expect(d2[0].messageText).toBe(`Property 'bar' does not exist on type 'TemplateReference'.`);
});
}); });

View File

@ -0,0 +1,49 @@
/**
* @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 {LanguageServiceAdapter} from '../language_service_adapter';
import {setup, TEST_TEMPLATE} from './mock_host';
const {project, service} = setup();
describe('Language service adapter', () => {
it('should register update if it has not seen the template before', () => {
const adapter = new LanguageServiceAdapter(project);
// Note that readResource() has never been called, so the adapter has no
// knowledge of the template at all.
const isRegistered = adapter.registerTemplateUpdate(TEST_TEMPLATE);
expect(isRegistered).toBeTrue();
expect(adapter.getModifiedResourceFiles().size).toBe(1);
});
it('should not register update if template has not changed', () => {
const adapter = new LanguageServiceAdapter(project);
adapter.readResource(TEST_TEMPLATE);
const isRegistered = adapter.registerTemplateUpdate(TEST_TEMPLATE);
expect(isRegistered).toBeFalse();
expect(adapter.getModifiedResourceFiles().size).toBe(0);
});
it('should register update if template has changed', () => {
const adapter = new LanguageServiceAdapter(project);
adapter.readResource(TEST_TEMPLATE);
service.overwrite(TEST_TEMPLATE, '<p>Hello World</p>');
const isRegistered = adapter.registerTemplateUpdate(TEST_TEMPLATE);
expect(isRegistered).toBe(true);
expect(adapter.getModifiedResourceFiles().size).toBe(1);
});
it('should clear template updates on read', () => {
const adapter = new LanguageServiceAdapter(project);
const isRegistered = adapter.registerTemplateUpdate(TEST_TEMPLATE);
expect(isRegistered).toBeTrue();
expect(adapter.getModifiedResourceFiles().size).toBe(1);
adapter.readResource(TEST_TEMPLATE);
expect(adapter.getModifiedResourceFiles().size).toBe(0);
});
});

View File

@ -8,6 +8,7 @@
import {join} from 'path'; import {join} from 'path';
import * as ts from 'typescript/lib/tsserverlibrary'; import * as ts from 'typescript/lib/tsserverlibrary';
import {isTypeScriptFile} from '../language_service_adapter';
const logger: ts.server.Logger = { const logger: ts.server.Logger = {
close(): void{}, close(): void{},
@ -161,10 +162,24 @@ class MockService {
getScriptInfo(fileName: string): ts.server.ScriptInfo { getScriptInfo(fileName: string): ts.server.ScriptInfo {
const scriptInfo = this.ps.getScriptInfo(fileName); const scriptInfo = this.ps.getScriptInfo(fileName);
if (!scriptInfo) { if (scriptInfo) {
return scriptInfo;
}
// There is no script info for external template, so create one.
// But we need to make sure it's not a TS file.
if (isTypeScriptFile(fileName)) {
throw new Error(`No existing script info for ${fileName}`); throw new Error(`No existing script info for ${fileName}`);
} }
return scriptInfo; const newScriptInfo = this.ps.getOrCreateScriptInfoForNormalizedPath(
ts.server.toNormalizedPath(fileName),
true, // openedByClient
'', // fileContent
ts.ScriptKind.External, // scriptKind
);
if (!newScriptInfo) {
throw new Error(`Failed to create new script info for ${fileName}`);
}
return newScriptInfo;
} }
/** /**