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:
parent
2b2a847ad7
commit
d482f5cdd3
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue