diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index fc8f886822..5ff0c40fed 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -6,11 +6,13 @@ * 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 {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; 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 {LanguageServiceAdapter, LSParseConfigHost} from './adapters'; @@ -22,6 +24,25 @@ import {ReferencesAndRenameBuilder} from './references'; import {getTargetAtPosition, TargetContext, TargetNodeKind} from './template_target'; 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 { private options: CompilerOptions; readonly compilerFactory: CompilerFactory; @@ -195,6 +216,58 @@ export class LanguageService { return result; } + getTcb(fileName: string, position: number): GetTcbResponse { + return this.withCompiler(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; + 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(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) { // TODO: Check the case when the project is disposed. An InferredProject // could be disposed when a tsconfig.json is added to the workspace, diff --git a/packages/language-service/ivy/test/env.ts b/packages/language-service/ivy/test/env.ts index f73e4fdfa7..697432be9f 100644 --- a/packages/language-service/ivy/test/env.ts +++ b/packages/language-service/ivy/test/env.ts @@ -230,8 +230,8 @@ function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration { export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} { const cursor = textWithCursor.indexOf('¦'); - if (cursor === -1) { - throw new Error(`Expected to find cursor symbol '¦'`); + if (cursor === -1 || textWithCursor.indexOf('¦', cursor + 1) !== -1) { + throw new Error(`Expected to find exactly one cursor symbol '¦'`); } return { diff --git a/packages/language-service/ivy/test/gettcb_spec.ts b/packages/language-service/ivy/test/gettcb_spec.ts new file mode 100644 index 0000000000..dec439941c --- /dev/null +++ b/packages/language-service/ivy/test/gettcb_spec.ts @@ -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: '
{{ my¦Prop }}
', + }) + 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(`
{{ my¦Prop }}
`); + 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: '
{{ myProp }}
', + }) + 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(); + }); +}); diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index d39c0946fc..6fae724673 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -7,9 +7,13 @@ */ 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 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 { ...tsLS, getSemanticDiagnostics, @@ -128,6 +136,7 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { getCompletionsAtPosition, getCompletionEntryDetails, getCompletionEntrySymbol, + getTcb, }; }