feat(language-service): view template typecheck block (#39974)

This patch adds an API to retrieve the template typecheck block for a
template (if any) at a file location, and a selection of the TS node
in the TCB corresponding to the template node at which the request for
a TCB was made (if any).

Probably not something we want to land soon, but a useful debugging tool
for folks working with TCBs.

PR Close #39974
This commit is contained in:
ayazhafiz 2020-12-04 10:33:27 -06:00 committed by Jessica Janiuk
parent 2b2a847ad7
commit d482f5cdd3
4 changed files with 179 additions and 5 deletions

View File

@ -6,11 +6,13 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {AST, TmplAstBoundEvent, TmplAstNode} from '@angular/compiler'; import {AbsoluteSourceSpan, AST, ParseSourceSpan, TmplAstBoundEvent, TmplAstNode} from '@angular/compiler';
import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli'; import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
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 {findFirstMatchingNode} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments';
import * as ts from 'typescript/lib/tsserverlibrary'; import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageServiceAdapter, LSParseConfigHost} from './adapters'; import {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
@ -22,6 +24,25 @@ import {ReferencesAndRenameBuilder} from './references';
import {getTargetAtPosition, TargetContext, TargetNodeKind} from './template_target'; import {getTargetAtPosition, TargetContext, TargetNodeKind} from './template_target';
import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
export type GetTcbResponse = {
/**
* The filename of the SourceFile this typecheck block belongs to.
* The filename is entirely opaque and unstable, useful only for debugging
* purposes.
*/
fileName: string,
/** The content of the SourceFile this typecheck block belongs to. */
content: string,
/**
* Spans over node(s) in the typecheck block corresponding to the
* TS code generated for template node under the current cursor position.
*
* When the cursor position is over a source for which there is no generated
* code, `selections` is empty.
*/
selections: ts.TextSpan[],
}|undefined;
export class LanguageService { export class LanguageService {
private options: CompilerOptions; private options: CompilerOptions;
readonly compilerFactory: CompilerFactory; readonly compilerFactory: CompilerFactory;
@ -195,6 +216,58 @@ export class LanguageService {
return result; return result;
} }
getTcb(fileName: string, position: number): GetTcbResponse {
return this.withCompiler<GetTcbResponse>(fileName, compiler => {
const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler);
if (templateInfo === undefined) {
return undefined;
}
const tcb = compiler.getTemplateTypeChecker().getTypeCheckBlock(templateInfo.component);
if (tcb === null) {
return undefined;
}
const sf = tcb.getSourceFile();
let selections: ts.TextSpan[] = [];
const target = getTargetAtPosition(templateInfo.template, position);
if (target !== null) {
let selectionSpans: Array<ParseSourceSpan|AbsoluteSourceSpan>;
if ('nodes' in target.context) {
selectionSpans = target.context.nodes.map(n => n.sourceSpan);
} else {
selectionSpans = [target.context.node.sourceSpan];
}
const selectionNodes: ts.Node[] =
selectionSpans
.map(s => findFirstMatchingNode(tcb, {
withSpan: s,
filter: (node: ts.Node): node is ts.Node => true,
}))
.filter((n): n is ts.Node => n !== null);
selections = selectionNodes.map(n => {
return {
start: n.getStart(sf),
length: n.getEnd() - n.getStart(sf),
};
});
}
return {
fileName: sf.fileName,
content: sf.getFullText(),
selections,
};
});
}
private withCompiler<T>(fileName: string, p: (compiler: NgCompiler) => T): T {
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const result = p(compiler);
this.compilerFactory.registerLastKnownProgram();
return result;
}
private watchConfigFile(project: ts.server.Project) { private watchConfigFile(project: ts.server.Project) {
// TODO: Check the case when the project is disposed. An InferredProject // TODO: Check the case when the project is disposed. An InferredProject
// could be disposed when a tsconfig.json is added to the workspace, // could be disposed when a tsconfig.json is added to the workspace,

View File

@ -230,8 +230,8 @@ function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration {
export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} { export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
const cursor = textWithCursor.indexOf('¦'); const cursor = textWithCursor.indexOf('¦');
if (cursor === -1) { if (cursor === -1 || textWithCursor.indexOf('¦', cursor + 1) !== -1) {
throw new Error(`Expected to find cursor symbol '¦'`); throw new Error(`Expected to find exactly one cursor symbol '¦'`);
} }
return { return {

View File

@ -0,0 +1,92 @@
/**
* @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 {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import {extractCursorInfo} from './env';
import {createModuleWithDeclarations} from './test_utils';
describe('get typecheck block', () => {
beforeEach(() => {
initMockFileSystem('Native');
});
it('should find the typecheck block for an inline template', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({
template: '<div>{{ my¦Prop }}</div>',
})
export class AppCmp {
myProp!: string;
}`);
const appFi = absoluteFrom('/app.ts');
const env = createModuleWithDeclarations([{name: appFi, contents: text}]);
env.expectNoSourceDiagnostics();
const result = env.ngLS.getTcb(appFi, cursor);
if (result === undefined) {
fail('Expected a valid TCB response');
return;
}
const {content, selections} = result;
expect(selections.length).toBe(1);
const {start, length} = selections[0];
expect(content.substring(start, start + length)).toContain('myProp');
});
it('should find the typecheck block for an external template', () => {
const {text, cursor} = extractCursorInfo(`<div>{{ my¦Prop }}</div>`);
const templateFi = absoluteFrom('/app.html');
const env = createModuleWithDeclarations(
[{
name: absoluteFrom('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({
templateUrl: './app.html',
})
export class AppCmp {
myProp!: string;
}`,
}],
[{name: templateFi, contents: text}]);
env.expectNoSourceDiagnostics();
const result = env.ngLS.getTcb(templateFi, cursor);
if (result === undefined) {
fail('Expected a valid TCB response');
return;
}
const {content, selections} = result;
expect(selections.length).toBe(1);
const {start, length} = selections[0];
expect(content.substring(start, start + length)).toContain('myProp');
});
it('should not find typecheck blocks outside a template', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({
template: '<div>{{ myProp }}</div>',
})
export class AppCmp {
my¦Prop!: string;
}`);
const appFi = absoluteFrom('/app.ts');
const env = createModuleWithDeclarations([{name: appFi, contents: text}]);
env.expectNoSourceDiagnostics();
const result = env.ngLS.getTcb(appFi, cursor);
expect(result).toBeUndefined();
});
});

View File

@ -7,9 +7,13 @@
*/ */
import * as ts from 'typescript/lib/tsserverlibrary'; import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageService} from './language_service'; import {GetTcbResponse, LanguageService} from './language_service';
export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { export interface NgLanguageService extends ts.LanguageService {
getTcb(fileName: string, position: number): GetTcbResponse;
}
export function create(info: ts.server.PluginCreateInfo): NgLanguageService {
const {project, languageService: tsLS, config} = info; const {project, languageService: tsLS, config} = info;
const angularOnly = config?.angularOnly === true; const angularOnly = config?.angularOnly === true;
@ -116,6 +120,10 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
} }
} }
function getTcb(fileName: string, position: number): GetTcbResponse {
return ngLS.getTcb(fileName, position);
}
return { return {
...tsLS, ...tsLS,
getSemanticDiagnostics, getSemanticDiagnostics,
@ -128,6 +136,7 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
getCompletionsAtPosition, getCompletionsAtPosition,
getCompletionEntryDetails, getCompletionEntryDetails,
getCompletionEntrySymbol, getCompletionEntrySymbol,
getTcb,
}; };
} }