From 18291b87d82ef848521236ddcfc9082d4f65a6b5 Mon Sep 17 00:00:00 2001 From: ayazhafiz Date: Fri, 4 Dec 2020 10:38:14 -0600 Subject: [PATCH] feat: Add Command to view template typecheck block This patch adds a command to retrieve and display the typecheck block for a template under the user's active selections (if any), and highlights the span of the node(s) in the typecheck block that correspond to the template node under the user's active selection (if any). The typecheck block is made available via a dedicated text document provider that queries fresh typecheck block content whenever the `getTemplateTcb` command is invoked. See also https://github.com/angular/angular/pull/39974, which provides the language service implementations needed for this feature. --- client/src/client.ts | 34 ++++++++++++++++++ client/src/commands.ts | 65 +++++++++++++++++++++++++++++++--- client/src/providers.ts | 62 ++++++++++++++++++++++++++++++++ common/requests.ts | 23 ++++++++++++ integration/lsp/ivy_spec.ts | 15 ++++++++ package.json | 16 ++++++++- server/src/session.ts | 30 ++++++++++++++-- server/src/utils.ts | 2 +- server/src/version_provider.ts | 2 +- 9 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 client/src/providers.ts create mode 100644 common/requests.ts diff --git a/client/src/client.ts b/client/src/client.ts index 394fe23543..b91599ac4a 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -13,9 +13,16 @@ import * as lsp from 'vscode-languageclient/node'; import {ProjectLoadingFinish, ProjectLoadingStart, SuggestIvyLanguageService, SuggestIvyLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../common/notifications'; import {NgccProgress, NgccProgressToken, NgccProgressType} from '../common/progress'; +import {GetTcbRequest} from '../common/requests'; import {ProgressReporter} from './progress-reporter'; +interface GetTcbResponse { + uri: vscode.Uri; + content: string; + selections: vscode.Range[]; +} + export class AngularLanguageClient implements vscode.Disposable { private client: lsp.LanguageClient|null = null; private readonly disposables: vscode.Disposable[] = []; @@ -93,6 +100,33 @@ export class AngularLanguageClient implements vscode.Disposable { this.client = null; } + /** + * Requests a template typecheck block at the current cursor location in the + * specified editor. + */ + async getTcbUnderCursor(textEditor: vscode.TextEditor): Promise { + if (this.client === null) { + return undefined; + } + const c2pConverter = this.client.code2ProtocolConverter; + // Craft a request by converting vscode params to LSP. The corresponding + // response is in LSP. + const response = await this.client.sendRequest(GetTcbRequest, { + textDocument: c2pConverter.asTextDocumentIdentifier(textEditor.document), + position: c2pConverter.asPosition(textEditor.selection.active), + }); + if (response === null) { + return undefined; + } + const p2cConverter = this.client.protocol2CodeConverter; + // Convert the response from LSP back to vscode. + return { + uri: p2cConverter.asUri(response.uri), + content: response.content, + selections: p2cConverter.asRanges(response.selections), + }; + } + get initializeResult(): lsp.InitializeResult|undefined { return this.client?.initializeResult; } diff --git a/client/src/commands.ts b/client/src/commands.ts index 7ee1a5e9f2..d9ed858ffa 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -9,14 +9,20 @@ import * as vscode from 'vscode'; import {ServerOptions} from '../common/initialize'; import {AngularLanguageClient} from './client'; +import {ANGULAR_SCHEME, TcbContentProvider} from './providers'; /** * Represent a vscode command with an ID and an impl function `execute`. */ -interface Command { - id: string; - execute(): Promise; -} +type Command = { + id: string, + isTextEditorCommand: false, + execute(): Promise, +}|{ + id: string, + isTextEditorCommand: true, + execute(textEditor: vscode.TextEditor): Promise, +}; /** * Restart the language server by killing the process then spanwing a new one. @@ -26,6 +32,7 @@ interface Command { function restartNgServer(client: AngularLanguageClient): Command { return { id: 'angular.restartNgServer', + isTextEditorCommand: false, async execute() { await client.stop(); await client.start(); @@ -39,6 +46,7 @@ function restartNgServer(client: AngularLanguageClient): Command { function openLogFile(client: AngularLanguageClient): Command { return { id: 'angular.openLogFile', + isTextEditorCommand: false, async execute() { const serverOptions: ServerOptions|undefined = client.initializeResult?.serverOptions; if (!serverOptions?.logFile) { @@ -64,6 +72,50 @@ function openLogFile(client: AngularLanguageClient): Command { }; } +/** + * Command getTemplateTcb displays a typecheck block for the template a user has + * an active selection over, if any. + * @param ngClient LSP client for the active session + * @param context extension context to which disposables are pushed + */ +function getTemplateTcb( + ngClient: AngularLanguageClient, context: vscode.ExtensionContext): Command { + const TCB_HIGHLIGHT_DECORATION = vscode.window.createTextEditorDecorationType({ + // See https://code.visualstudio.com/api/references/theme-color#editor-colors + backgroundColor: new vscode.ThemeColor('editor.selectionHighlightBackground'), + }); + + const tcbProvider = new TcbContentProvider(); + const disposable = vscode.workspace.registerTextDocumentContentProvider( + ANGULAR_SCHEME, + tcbProvider, + ); + context.subscriptions.push(disposable); + + return { + id: 'angular.getTemplateTcb', + isTextEditorCommand: true, + async execute(textEditor: vscode.TextEditor) { + tcbProvider.clear(); + const response = await ngClient.getTcbUnderCursor(textEditor); + if (response === undefined) { + return undefined; + } + // Change the scheme of the URI from `file` to `ng` so that the document + // content is requested from our own `TcbContentProvider`. + const tcbUri = response.uri.with({ + scheme: ANGULAR_SCHEME, + }); + tcbProvider.update(tcbUri, response.content); + const editor = await vscode.window.showTextDocument(tcbUri, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, // cursor remains in the active editor + }); + editor.setDecorations(TCB_HIGHLIGHT_DECORATION, response.selections); + } + }; +} + /** * Register all supported vscode commands for the Angular extension. * @param client language client @@ -74,10 +126,13 @@ export function registerCommands( const commands: Command[] = [ restartNgServer(client), openLogFile(client), + getTemplateTcb(client, context), ]; for (const command of commands) { - const disposable = vscode.commands.registerCommand(command.id, command.execute); + const disposable = command.isTextEditorCommand ? + vscode.commands.registerTextEditorCommand(command.id, command.execute) : + vscode.commands.registerCommand(command.id, command.execute); context.subscriptions.push(disposable); } } diff --git a/client/src/providers.ts b/client/src/providers.ts new file mode 100644 index 0000000000..88032f722f --- /dev/null +++ b/client/src/providers.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google Inc. 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 * as vscode from 'vscode'; + +export const ANGULAR_SCHEME = 'ng'; + +/** + * Allocate a provider of documents corresponding to the `ng` URI scheme, + * which we will use to provide a virtual document with the TCB contents. + * + * We use a virtual document provider rather than opening an untitled file to + * ensure the buffer remains readonly (https://github.com/microsoft/vscode/issues/4873). + */ +export class TcbContentProvider implements vscode.TextDocumentContentProvider { + /** + * Event emitter used to notify VSCode of a change to the TCB virtual document, + * prompting it to re-evaluate the document content. This is needed to bust + * VSCode's document cache if someone requests a TCB that was previously opened. + * https://code.visualstudio.com/api/extension-guides/virtual-documents#update-virtual-documents + */ + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + /** + * Name of the typecheck file. + */ + private tcbFile: vscode.Uri|null = null; + /** + * Content of the entire typecheck file. + */ + private tcbContent: string|null = null; + + /** + * This callback is invoked only when user explicitly requests to view or + * update typecheck file. We do not automatically update the typecheck document + * when the source file changes. + */ + readonly onDidChange = this.onDidChangeEmitter.event; + + provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): + vscode.ProviderResult { + if (uri.toString() !== this.tcbFile?.toString()) { + return null; + } + return this.tcbContent; + } + + update(uri: vscode.Uri, content: string) { + this.tcbFile = uri; + this.tcbContent = content; + this.onDidChangeEmitter.fire(uri); + } + + clear() { + this.tcbFile = null; + this.tcbContent = null; + } +} diff --git a/common/requests.ts b/common/requests.ts new file mode 100644 index 0000000000..a93c80e78d --- /dev/null +++ b/common/requests.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. 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 * as lsp from 'vscode-languageserver-protocol'; + +export interface GetTcbParams { + textDocument: lsp.TextDocumentIdentifier; + position: lsp.Position; +} + +export const GetTcbRequest = + new lsp.RequestType('angular/getTcb'); + +export interface GetTcbResponse { + uri: lsp.DocumentUri; + content: string; + selections: lsp.Range[] +} diff --git a/integration/lsp/ivy_spec.ts b/integration/lsp/ivy_spec.ts index 962b1d779d..5fa1e36576 100644 --- a/integration/lsp/ivy_spec.ts +++ b/integration/lsp/ivy_spec.ts @@ -13,6 +13,7 @@ import {URI} from 'vscode-uri'; import {ProjectLanguageService, ProjectLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../../common/notifications'; import {NgccProgress, NgccProgressToken, NgccProgressType} from '../../common/progress'; +import {GetTcbRequest} from '../../common/requests'; import {APP_COMPONENT, createConnection, createTracer, FOO_COMPONENT, FOO_TEMPLATE, initializeServer, openTextDocument, TSCONFIG} from './test_utils'; @@ -312,6 +313,20 @@ describe('Angular Ivy language server', () => { }); }); }); + + describe('getTcb', () => { + it('should handle getTcb request', async () => { + openTextDocument(client, FOO_TEMPLATE); + await waitForNgcc(client); + const response = await client.sendRequest(GetTcbRequest, { + textDocument: { + uri: `file://${FOO_TEMPLATE}`, + }, + position: {line: 0, character: 3}, + }); + expect(response).toBeDefined(); + }); + }); }); function onNgccProgress(client: MessageConnection): Promise { diff --git a/package.json b/package.json index 6bf37e1a38..b6f17c7514 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,22 @@ "command": "angular.openLogFile", "title": "Open Angular Server log", "category": "Angular" + }, + { + "command": "angular.getTemplateTcb", + "title": "View Template Typecheck Block", + "category": "Angular" } ], + "menus": { + "editor/context": [ + { + "when": "resourceLangId == html || resourceLangId == typescript", + "command": "angular.getTemplateTcb", + "group": "angular" + } + ] + }, "configuration": { "title": "Angular Language Service", "properties": { @@ -164,4 +178,4 @@ "type": "git", "url": "https://github.com/angular/vscode-ng-language-service" } -} \ No newline at end of file +} diff --git a/server/src/session.ts b/server/src/session.ts index 2042a7d5fa..17b81de63c 100644 --- a/server/src/session.ts +++ b/server/src/session.ts @@ -6,12 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ +import {NgLanguageService} from '@angular/language-service'; import * as ts from 'typescript/lib/tsserverlibrary'; import * as lsp from 'vscode-languageserver/node'; import {ServerOptions} from '../common/initialize'; import {ProjectLanguageService, ProjectLoadingFinish, ProjectLoadingStart, SuggestIvyLanguageService, SuggestStrictMode} from '../common/notifications'; import {NgccProgressToken, NgccProgressType} from '../common/progress'; +import {GetTcbParams, GetTcbRequest, GetTcbResponse} from '../common/requests'; import {readNgCompletionData, tsCompletionEntryToLspCompletionItem} from './completion'; import {tsDiagnosticToLspDiagnostic} from './diagnostic'; @@ -132,6 +134,30 @@ export class Session { conn.onHover(p => this.onHover(p)); conn.onCompletion(p => this.onCompletion(p)); conn.onCompletionResolve(p => this.onCompletionResolve(p)); + conn.onRequest(GetTcbRequest, p => this.onGetTcb(p)); + } + + private onGetTcb(params: GetTcbParams): GetTcbResponse|undefined { + const lsInfo = this.getLSAndScriptInfo(params.textDocument); + if (lsInfo === undefined) { + return undefined; + } + const {languageService, scriptInfo} = lsInfo; + const offset = lspPositionToTsPosition(scriptInfo, params.position); + const response = languageService.getTcb(scriptInfo.fileName, offset); + if (response === undefined) { + return undefined; + } + const {fileName: tcfName} = response; + const tcfScriptInfo = this.projectService.getScriptInfo(tcfName); + if (!tcfScriptInfo) { + return undefined; + } + return { + uri: filePathToUri(tcfName), + content: response.content, + selections: response.selections.map((span => tsTextSpanToLspRange(tcfScriptInfo, span))), + }; } private async runNgcc(configFilePath: string) { @@ -663,7 +689,7 @@ export class Session { } private getLSAndScriptInfo(textDocumentOrFileName: lsp.TextDocumentIdentifier|string): - {languageService: ts.LanguageService, scriptInfo: ts.server.ScriptInfo}|undefined { + {languageService: NgLanguageService, scriptInfo: ts.server.ScriptInfo}|undefined { const filePath = lsp.TextDocumentIdentifier.is(textDocumentOrFileName) ? uriToFilePath(textDocumentOrFileName.uri) : textDocumentOrFileName; @@ -683,7 +709,7 @@ export class Session { return undefined; } return { - languageService: project.getLanguageService(), + languageService: project.getLanguageService() as NgLanguageService, scriptInfo, }; } diff --git a/server/src/utils.ts b/server/src/utils.ts index d070683afd..798feadd1f 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -33,7 +33,7 @@ export function uriToFilePath(uri: string): string { * Converts the specified `filePath` to a proper URI. * @param filePath */ -export function filePathToUri(filePath: string): string { +export function filePathToUri(filePath: string): lsp.DocumentUri { return URI.file(filePath).toString(); } diff --git a/server/src/version_provider.ts b/server/src/version_provider.ts index 7711c7830d..a509e648e1 100644 --- a/server/src/version_provider.ts +++ b/server/src/version_provider.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; const MIN_TS_VERSION = '4.1'; -const MIN_NG_VERSION = '11.1'; +const MIN_NG_VERSION = '11.2'; /** * Represents a valid node module that has been successfully resolved.