Skip to content

Commit

Permalink
feat(server): provide folding ranges for inline templates
Browse files Browse the repository at this point in the history
Following the embedded language support documentation, this commit provides
folding ranges for inline templates in typescript files.
This feature is provided in the language server so other editors that
use the @angular/language-server package can make use of the feature as
well.

https://code.visualstudio.com/api/language-extensions/embedded-languages

fixes #852
  • Loading branch information
atscott committed Oct 19, 2022
1 parent 6f43791 commit 67f4fe7
Show file tree
Hide file tree
Showing 10 changed files with 217 additions and 4 deletions.
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ npm_package(
":node_modules/semver",
":node_modules/typescript",
":node_modules/vscode-jsonrpc",
":node_modules/vscode-html-languageservice",
":node_modules/vscode-languageclient",
":node_modules/vscode-languageserver-protocol",
":node_modules/vscode-languageserver-types",
Expand Down
10 changes: 9 additions & 1 deletion client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,15 @@ export class AngularLanguageClient implements vscode.Disposable {
}

return angularCompletionsPromise;
}
},
provideFoldingRanges: async (
document: vscode.TextDocument, context: vscode.FoldingContext,
token: vscode.CancellationToken, next) => {
if (!(await this.isInAngularProject(document)) || document.languageId !== 'typescript') {
return null;
}
return next(document, context, token);
},
}
};
}
Expand Down
32 changes: 32 additions & 0 deletions integration/lsp/ivy_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,38 @@ describe('Angular Ivy language server', () => {
expect(targetUri).toContain('libs/post/src/lib/post.component.ts');
});

it('provides folding ranges for inline templates', async () => {
openTextDocument(client, APP_COMPONENT, `
import {Component, EventEmitter, Input, Output} from '@angular/core';
@Component({
selector: 'my-app',
template: \`
<div>
<span>
Hello {{name}}
</span>
</div>\`,
})
export class AppComponent {
name = 'Angular';
@Input() appInput = '';
@Output() appOutput = new EventEmitter<string>();
}`);
const languageServiceEnabled = await waitForNgcc(client);
expect(languageServiceEnabled).toBeTrue();
const response = await client.sendRequest(lsp.FoldingRangeRequest.type, {
textDocument: {
uri: APP_COMPONENT_URI,
},
}) as lsp.FoldingRange[];
expect(Array.isArray(response)).toBe(true);
// 1 folding range for the div, 1 for the span
expect(response.length).toEqual(2);
expect(response).toContain({startLine: 6, endLine: 9});
expect(response).toContain({startLine: 7, endLine: 8});
});

describe('signature help', () => {
it('should show signature help for an empty call', async () => {
client.sendNotification(lsp.DidOpenTextDocumentNotification.type, {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
"tslint": "6.1.3",
"tslint-eslint-rules": "5.4.0",
"vsce": "1.100.1",
"vscode-html-languageservice": "^5.0.2",
"vscode-languageserver-protocol": "3.16.0",
"vscode-languageserver-textdocument": "1.0.7",
"vscode-test": "1.6.1",
Expand All @@ -257,4 +258,4 @@
"type": "git",
"url": "https://github.com/angular/vscode-ng-language-service"
}
}
}
2 changes: 2 additions & 0 deletions server/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ esbuild(
"vscode-languageserver",
"vscode-uri",
"vscode-jsonrpc",
"vscode-languageserver-textdocument",
"vscode-html-languageservice",
],
config = "esbuild.mjs",
# Do not enable minification. It seems to break the extension on Windows (with WSL). See #1198.
Expand Down
4 changes: 3 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
},
"dependencies": {
"@angular/language-service": "15.0.0-next.0",
"vscode-html-languageservice": "^5.0.2",
"vscode-jsonrpc": "6.0.0",
"vscode-languageserver": "7.0.0",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-uri": "3.0.3"
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com"
}
}
}
2 changes: 2 additions & 0 deletions server/src/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ ts_project(
"//:node_modules/@angular/language-service",
"//:node_modules/@types/node",
"//:node_modules/typescript",
"//:node_modules/vscode-html-languageservice",
"//:node_modules/vscode-languageserver",
"//:node_modules/vscode-languageserver-textdocument",
"//:node_modules/vscode-uri",
"//common",
],
Expand Down
116 changes: 116 additions & 0 deletions server/src/embedded_support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @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 ts from 'typescript';

/**
* Takes a TS file and strips out all non-inline template content.
*
* This process is the same as what's done in the VSCode example for embedded languages.
*
* Note that the example below implements the support on the client side. This is done on the server
* side to enable language services in other editors to take advantage of this feature by depending
* on the @angular/language-server package.
*
* @see https://github.com/microsoft/vscode-extension-samples/blob/fdd3bb95ce8e38ffe58fc9158797239fdf5017f1/lsp-embedded-request-forwarding/client/src/embeddedSupport.ts#L131-L141
* @see https://code.visualstudio.com/api/language-extensions/embedded-languages
*/
export function getHTMLVirtualContent(documentText: string): string {
const sf =
ts.createSourceFile('temp', documentText, ts.ScriptTarget.ESNext, true /* setParentNodes */);
const inlineTemplateNodes: ts.Node[] = findAllMatchingNodes(sf, isInlineTemplateNode);

// Create a blank document with same text length
let content = documentText.split('\n')
.map(line => {
return ' '.repeat(line.length);
})
.join('\n');

// add back all the inline template regions in-place
inlineTemplateNodes.forEach(r => {
content = content.slice(0, r.getStart(sf) + 1) +
documentText.slice(r.getStart(sf) + 1, r.getEnd() - 1) + content.slice(r.getEnd() - 1);
});
return content;
}

function isInlineTemplateNode(node: ts.Node) {
const assignment = getPropertyAssignmentFromValue(node, 'template');
return ts.isStringLiteralLike(node) && assignment !== null &&
getClassDeclFromDecoratorProp(assignment) !== null;
}

/**
* Returns a property assignment from the assignment value if the property name
* matches the specified `key`, or `null` if there is no match.
*/
export function getPropertyAssignmentFromValue(value: ts.Node, key: string): ts.PropertyAssignment|
null {
const propAssignment = value.parent;
if (!propAssignment || !ts.isPropertyAssignment(propAssignment) ||
propAssignment.name.getText() !== key) {
return null;
}
return propAssignment;
}

/**
* Given a decorator property assignment, return the ClassDeclaration node that corresponds to the
* directive class the property applies to.
* If the property assignment is not on a class decorator, no declaration is returned.
*
* For example,
*
* @Component({
* template: '<div></div>'
* ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment
* })
* class AppComponent {}
* ^---- class declaration node
*
* @param propAsgnNode property assignment
*/
export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignment):
ts.ClassDeclaration|undefined {
if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) {
return;
}
const objLitExprNode = propAsgnNode.parent;
if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) {
return;
}
const callExprNode = objLitExprNode.parent;
if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) {
return;
}
const decorator = callExprNode.parent;
if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) {
return;
}
const classDeclNode = decorator.parent;
return classDeclNode;
}

export function findAllMatchingNodes(
sf: ts.SourceFile, filter: (node: ts.Node) => boolean): ts.Node[] {
const results: ts.Node[] = [];
const stack: ts.Node[] = [sf];

while (stack.length > 0) {
const node = stack.pop()!;

if (filter(node)) {
results.push(node);
} else {
stack.push(...node.getChildren());
}
}

return results;
}
24 changes: 24 additions & 0 deletions server/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api';
import * as ts from 'typescript/lib/tsserverlibrary';
import {promisify} from 'util';
import {getLanguageService as getHTMLLanguageService} from 'vscode-html-languageservice';
import {TextDocument} from 'vscode-languageserver-textdocument';
import * as lsp from 'vscode-languageserver/node';

import {ServerOptions} from '../../common/initialize';
Expand All @@ -17,6 +19,7 @@ import {GetComponentsWithTemplateFile, GetTcbParams, GetTcbRequest, GetTcbRespon

import {readNgCompletionData, tsCompletionEntryToLspCompletionItem} from './completion';
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
import {getHTMLVirtualContent} from './embedded_support';
import {resolveAndRunNgcc} from './ngcc';
import {ServerHost} from './server_host';
import {filePathToUri, getMappedDefinitionInfo, isConfiguredProject, isDebugMode, lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, tsDisplayPartsToText, tsFileTextChangesToLspWorkspaceEdit, tsTextSpanToLspRange, uriToFilePath} from './utils';
Expand Down Expand Up @@ -51,6 +54,8 @@ enum NgccErrorMessageAction {
const defaultFormatOptions: ts.FormatCodeSettings = {};
const defaultPreferences: ts.UserPreferences = {};

const htmlLS = getHTMLLanguageService();

/**
* Session is a wrapper around lsp.IConnection, with all the necessary protocol
* handlers installed for Angular language service.
Expand Down Expand Up @@ -190,6 +195,7 @@ export class Session {
conn.onRenameRequest(p => this.onRenameRequest(p));
conn.onPrepareRename(p => this.onPrepareRename(p));
conn.onHover(p => this.onHover(p));
conn.onFoldingRanges(p => this.onFoldingRanges(p));
conn.onCompletion(p => this.onCompletion(p));
conn.onCompletionResolve(p => this.onCompletionResolve(p));
conn.onRequest(GetComponentsWithTemplateFile, p => this.onGetComponentsWithTemplateFile(p));
Expand Down Expand Up @@ -710,6 +716,7 @@ export class Session {
this.clientCapabilities = params.capabilities;
return {
capabilities: {
foldingRangeProvider: true,
codeLensProvider: this.ivy ? {resolveProvider: true} : undefined,
textDocumentSync: lsp.TextDocumentSyncKind.Incremental,
completionProvider: {
Expand Down Expand Up @@ -854,6 +861,23 @@ export class Session {
}
}

private onFoldingRanges(params: lsp.FoldingRangeParams) {
if (!params.textDocument.uri?.endsWith('ts')) {
return null;
}

const lsInfo = this.getLSAndScriptInfo(params.textDocument);
if (lsInfo === null) {
return;
}
const {scriptInfo} = lsInfo;
const docText = scriptInfo.getSnapshot().getText(0, scriptInfo.getSnapshot().getLength());
const virtualHtmlDocContents = getHTMLVirtualContent(docText);
const virtualHtmlDoc =
TextDocument.create(params.textDocument.uri.toString(), 'html', 0, virtualHtmlDocContents);
return htmlLS.getFoldingRanges(virtualHtmlDoc);
}

private onDefinition(params: lsp.TextDocumentPositionParams): lsp.LocationLink[]|null {
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
if (lsInfo === null) {
Expand Down
27 changes: 26 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7573,6 +7573,16 @@ vsce@1.100.1:
yauzl "^2.3.1"
yazl "^2.2.2"

vscode-html-languageservice@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-5.0.2.tgz#a66cb9d779f3094a8d14dd3a8f7935748435fd2a"
integrity sha512-TQmeyE14Ure/w/S+RV2IItuRWmw/i1QaS+om6t70iHCpamuTTWnACQPMSltVGm/DlbdyMquUePJREjd/h3AVkQ==
dependencies:
vscode-languageserver-textdocument "^1.0.7"
vscode-languageserver-types "^3.17.2"
vscode-nls "^5.2.0"
vscode-uri "^3.0.4"

vscode-jsonrpc@6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz#108bdb09b4400705176b957ceca9e0880e9b6d4e"
Expand All @@ -7595,7 +7605,7 @@ vscode-languageserver-protocol@3.16.0:
vscode-jsonrpc "6.0.0"
vscode-languageserver-types "3.16.0"

vscode-languageserver-textdocument@1.0.7:
vscode-languageserver-textdocument@1.0.7, vscode-languageserver-textdocument@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.7.tgz#16df468d5c2606103c90554ae05f9f3d335b771b"
integrity sha512-bFJH7UQxlXT8kKeyiyu41r22jCZXG8kuuVVA33OEJn1diWOZK5n8zBSPZFHVBOu8kXZ6h0LIRhf5UnCo61J4Hg==
Expand All @@ -7605,13 +7615,23 @@ vscode-languageserver-types@3.16.0:
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz#ecf393fc121ec6974b2da3efb3155644c514e247"
integrity sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==

vscode-languageserver-types@^3.17.2:
version "3.17.2"
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz#b2c2e7de405ad3d73a883e91989b850170ffc4f2"
integrity sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA==

vscode-languageserver@7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-7.0.0.tgz#49b068c87cfcca93a356969d20f5d9bdd501c6b0"
integrity sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==
dependencies:
vscode-languageserver-protocol "3.16.0"

vscode-nls@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.2.0.tgz#3cb6893dd9bd695244d8a024bdf746eea665cc3f"
integrity sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==

vscode-oniguruma@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.5.1.tgz#9ca10cd3ada128bd6380344ea28844243d11f695"
Expand Down Expand Up @@ -7649,6 +7669,11 @@ vscode-uri@3.0.3:
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84"
integrity sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA==

vscode-uri@^3.0.4:
version "3.0.6"
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.6.tgz#5e6e2e1a4170543af30151b561a41f71db1d6f91"
integrity sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ==

watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
Expand Down

0 comments on commit 67f4fe7

Please sign in to comment.