Skip to content

Commit

Permalink
feat: Add Command to view template typecheck block
Browse files Browse the repository at this point in the history
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 angular/angular#39974, which provides
the language service implementations needed for this feature.
  • Loading branch information
ayazhafiz authored and Keen Yee Liau committed Feb 11, 2021
1 parent cd3ed23 commit 18291b8
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 10 deletions.
34 changes: 34 additions & 0 deletions client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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<GetTcbResponse|undefined> {
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;
}
Expand Down
65 changes: 60 additions & 5 deletions client/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
}
type Command = {
id: string,
isTextEditorCommand: false,
execute(): Promise<unknown>,
}|{
id: string,
isTextEditorCommand: true,
execute(textEditor: vscode.TextEditor): Promise<unknown>,
};

/**
* Restart the language server by killing the process then spanwing a new one.
Expand All @@ -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();
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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);
}
}
62 changes: 62 additions & 0 deletions client/src/providers.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.Uri>();
/**
* 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<string> {
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;
}
}
23 changes: 23 additions & 0 deletions common/requests.ts
Original file line number Diff line number Diff line change
@@ -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<GetTcbParams, GetTcbResponse|null, /* error */ void>('angular/getTcb');

export interface GetTcbResponse {
uri: lsp.DocumentUri;
content: string;
selections: lsp.Range[]
}
15 changes: 15 additions & 0 deletions integration/lsp/ivy_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> {
Expand Down
16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -164,4 +178,4 @@
"type": "git",
"url": "https://github.com/angular/vscode-ng-language-service"
}
}
}
30 changes: 28 additions & 2 deletions server/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -683,7 +709,7 @@ export class Session {
return undefined;
}
return {
languageService: project.getLanguageService(),
languageService: project.getLanguageService() as NgLanguageService,
scriptInfo,
};
}
Expand Down
2 changes: 1 addition & 1 deletion server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/version_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 18291b8

Please sign in to comment.