Skip to content

fix(language-service): [Ivy] create compiler only when program changes #39231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/language-service/ivy/compiler_factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @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 {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {NgCompilerOptions} from '@angular/compiler-cli/src/ngtsc/core/api';
import {TrackedIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental';
import {TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript/lib/tsserverlibrary';

import {isExternalTemplate, LanguageServiceAdapter} from './language_service_adapter';

export class CompilerFactory {
private readonly incrementalStrategy = new TrackedIncrementalBuildStrategy();
private compiler: NgCompiler|null = null;
private lastKnownProgram: ts.Program|null = null;

constructor(
private readonly adapter: LanguageServiceAdapter,
private readonly programStrategy: TypeCheckingProgramStrategy,
) {}

/**
* Create a new instance of the Ivy compiler if the program has changed since
* the last time the compiler was instantiated. If the program has not changed,
* return the existing instance.
* @param fileName override the template if this is an external template file
* @param options angular compiler options
*/
getOrCreateWithChangedFile(fileName: string, options: NgCompilerOptions): NgCompiler {
const program = this.programStrategy.getProgram();
if (!this.compiler || program !== this.lastKnownProgram) {
this.compiler = new NgCompiler(
this.adapter, // like compiler host
options, // angular compiler options
program,
this.programStrategy,
this.incrementalStrategy,
true, // enableTemplateTypeChecker
this.lastKnownProgram,
undefined, // perfRecorder (use default)
);
this.lastKnownProgram = program;
}
if (isExternalTemplate(fileName)) {
this.overrideTemplate(fileName, this.compiler);
}
return this.compiler;
}

private overrideTemplate(fileName: string, compiler: NgCompiler) {
if (!this.adapter.isTemplateDirty(fileName)) {
return;
}
// 1. Get the latest snapshot
const latestTemplate = this.adapter.readResource(fileName);
// 2. Find all components that use the template
const ttc = compiler.getTemplateTypeChecker();
const components = compiler.getComponentsWithTemplateFile(fileName);
// 3. Update component template
for (const component of components) {
if (ts.isClassDeclaration(component)) {
ttc.overrideComponentTemplate(component, latestTemplate);
}
}
}

registerLastKnownProgram() {
this.lastKnownProgram = this.programStrategy.getProgram();
}
}
57 changes: 20 additions & 37 deletions packages/language-service/ivy/language_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,35 @@
import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {PatchedProgramIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental';
import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck';
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript/lib/tsserverlibrary';

import {CompilerFactory} from './compiler_factory';
import {DefinitionBuilder} from './definitions';
import {isExternalTemplate, isTypeScriptFile, LanguageServiceAdapter} from './language_service_adapter';
import {QuickInfoBuilder} from './quick_info';

export class LanguageService {
private options: CompilerOptions;
private lastKnownProgram: ts.Program|null = null;
private readonly compilerFactory: CompilerFactory;
private readonly strategy: TypeCheckingProgramStrategy;
private readonly adapter: LanguageServiceAdapter;

constructor(project: ts.server.Project, private readonly tsLS: ts.LanguageService) {
this.options = parseNgCompilerOptions(project);
this.strategy = createTypeCheckingProgramStrategy(project);
this.adapter = new LanguageServiceAdapter(project);
this.compilerFactory = new CompilerFactory(this.adapter, this.strategy);
this.watchConfigFile(project);
}

getSemanticDiagnostics(fileName: string): ts.Diagnostic[] {
const program = this.strategy.getProgram();
const compiler = this.createCompiler(program, fileName);
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options);
const ttc = compiler.getTemplateTypeChecker();
const diagnostics: ts.Diagnostic[] = [];
if (isTypeScriptFile(fileName)) {
const program = compiler.getNextProgram();
const sourceFile = program.getSourceFile(fileName);
if (sourceFile) {
diagnostics.push(...ttc.getDiagnosticsForFile(sourceFile, OptimizeFor.SingleFile));
Expand All @@ -49,51 +50,33 @@ export class LanguageService {
}
}
}
this.lastKnownProgram = compiler.getNextProgram();
this.compilerFactory.registerLastKnownProgram();
return diagnostics;
}

getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan
|undefined {
const program = this.strategy.getProgram();
const compiler = this.createCompiler(program, fileName);
return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position);
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options);
const results =
new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position);
this.compilerFactory.registerLastKnownProgram();
return results;
}

getTypeDefinitionAtPosition(fileName: string, position: number):
readonly ts.DefinitionInfo[]|undefined {
const program = this.strategy.getProgram();
const compiler = this.createCompiler(program, fileName);
return new DefinitionBuilder(this.tsLS, compiler)
.getTypeDefinitionsAtPosition(fileName, position);
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options);
const results =
new DefinitionBuilder(this.tsLS, compiler).getTypeDefinitionsAtPosition(fileName, position);
this.compilerFactory.registerLastKnownProgram();
return results;
}

getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
const program = this.strategy.getProgram();
const compiler = this.createCompiler(program, fileName);
return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position);
}

/**
* 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(
this.adapter,
this.options,
program,
this.strategy,
new PatchedProgramIncrementalBuildStrategy(),
/** enableTemplateTypeChecker */ true,
this.lastKnownProgram,
/** perfRecorder (use default) */ undefined,
);
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options);
const results = new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position);
this.compilerFactory.registerLastKnownProgram();
return results;
}

private watchConfigFile(project: ts.server.Project) {
Expand Down
29 changes: 2 additions & 27 deletions packages/language-service/ivy/language_service_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export class LanguageServiceAdapter implements NgCompilerAdapter {
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) || [];
Expand Down Expand Up @@ -68,37 +67,13 @@ export class LanguageServiceAdapter implements NgCompilerAdapter {
}
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;
}
isTemplateDirty(fileName: string): boolean {
const lastVersion = this.templateVersion.get(fileName);
const latestVersion = this.project.getScriptVersion(fileName);
if (lastVersion !== latestVersion) {
this.modifiedTemplates.add(fileName);
return true;
}
return false;
return lastVersion !== latestVersion;
}
}

Expand Down
78 changes: 78 additions & 0 deletions packages/language-service/ivy/test/compiler_factory_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @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 {APP_COMPONENT, setup, TEST_TEMPLATE} from './mock_host';

const {project, service} = setup();

/**
* The following specs do not directly test the CompilerFactory class, rather
* it asserts our understanding of the project/program/template interaction
* which forms the underlying assumption used in the CompilerFactory.
*/

describe('tsserver', () => {
beforeEach(() => {
service.reset();
});

describe('project version', () => {
it('is updated after TS changes', () => {
const v0 = project.getProjectVersion();
service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
const v1 = project.getProjectVersion();
expect(v1).not.toBe(v0);
});

it('is updated after template changes', () => {
const v0 = project.getProjectVersion();
service.overwrite(TEST_TEMPLATE, `{{ bar }}`);
const v1 = project.getProjectVersion();
expect(v1).not.toBe(v0);
});
});

describe('program', () => {
it('is updated after TS changes', () => {
const p0 = project.getLanguageService().getProgram();
expect(p0).toBeDefined();
service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
const p1 = project.getLanguageService().getProgram();
expect(p1).not.toBe(p0);
});

it('is not updated after template changes', () => {
const p0 = project.getLanguageService().getProgram();
expect(p0).toBeDefined();
service.overwrite(TEST_TEMPLATE, `{{ bar }}`);
const p1 = project.getLanguageService().getProgram();
expect(p1).toBe(p0);
});
});

describe('external template', () => {
it('should not be part of the project root', () => {
// Templates should _never_ be added to the project root, otherwise they
// will be parsed as TS files.
const scriptInfo = service.getScriptInfo(TEST_TEMPLATE);
expect(project.isRoot(scriptInfo)).toBeFalse();
});

it('should be attached to project', () => {
const scriptInfo = service.getScriptInfo(TEST_TEMPLATE);
// Script info for external template must be attached to a project because
// that's the primary mechanism used in the extension to locate the right
// language service instance. See
// https://github.com/angular/vscode-ng-language-service/blob/b048f5f6b9996c5cf113b764c7ffe13d9eeb4285/server/src/session.ts#L192
expect(scriptInfo.isAttached(project)).toBeTrue();
// Attaching a script info to a project does not mean that the project
// will contain the script info. Kinda like friendship on social media.
expect(project.containsScriptInfo(scriptInfo)).toBeFalse();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,20 @@ 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', () => {
it('should mark template dirty 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);
expect(adapter.isTemplateDirty(TEST_TEMPLATE)).toBeTrue();
});

it('should not register update if template has not changed', () => {
it('should not mark template dirty 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);
expect(adapter.isTemplateDirty(TEST_TEMPLATE)).toBeFalse();
});

it('should register update if template has changed', () => {
it('should mark template dirty 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);
expect(adapter.isTemplateDirty(TEST_TEMPLATE)).toBeTrue();
});
});
Loading