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.