Skip to content

Commit b2ec117

Browse files
committed
feat(server): provide folding ranges for inline templates
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
1 parent f5f7bb1 commit b2ec117

File tree

11 files changed

+219
-4
lines changed

11 files changed

+219
-4
lines changed

BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ npm_package(
5454
":node_modules/semver",
5555
":node_modules/typescript",
5656
":node_modules/vscode-jsonrpc",
57+
":node_modules/vscode-html-languageservice",
5758
":node_modules/vscode-languageclient",
5859
":node_modules/vscode-languageserver-protocol",
5960
":node_modules/vscode-languageserver-types",

client/src/client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,15 @@ export class AngularLanguageClient implements vscode.Disposable {
151151
}
152152

153153
return angularCompletionsPromise;
154-
}
154+
},
155+
provideFoldingRanges: async (
156+
document: vscode.TextDocument, context: vscode.FoldingContext,
157+
token: vscode.CancellationToken, next) => {
158+
if (!(await this.isInAngularProject(document)) || document.languageId !== 'typescript') {
159+
return null;
160+
}
161+
return next(document, context, token);
162+
},
155163
}
156164
};
157165
}

esbuild.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const serverConfig = {
5050
'path',
5151
'typescript/lib/tsserverlibrary',
5252
'vscode-languageserver',
53+
'vscode-languageserver-textdocument',
54+
'vscode-html-languageservice',
5355
'vscode-uri',
5456
'vscode-jsonrpc',
5557
],

integration/lsp/ivy_spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,38 @@ describe('Angular Ivy language server', () => {
181181
expect(targetUri).toContain('libs/post/src/lib/post.component.ts');
182182
});
183183

184+
it('provides folding ranges for inline templates', async () => {
185+
openTextDocument(client, APP_COMPONENT, `
186+
import {Component, EventEmitter, Input, Output} from '@angular/core';
187+
188+
@Component({
189+
selector: 'my-app',
190+
template: \`
191+
<div>
192+
<span>
193+
Hello {{name}}
194+
</span>
195+
</div>\`,
196+
})
197+
export class AppComponent {
198+
name = 'Angular';
199+
@Input() appInput = '';
200+
@Output() appOutput = new EventEmitter<string>();
201+
}`);
202+
const languageServiceEnabled = await waitForNgcc(client);
203+
expect(languageServiceEnabled).toBeTrue();
204+
const response = await client.sendRequest(lsp.FoldingRangeRequest.type, {
205+
textDocument: {
206+
uri: APP_COMPONENT_URI,
207+
},
208+
}) as lsp.FoldingRange[];
209+
expect(Array.isArray(response)).toBe(true);
210+
// 1 folding range for the div, 1 for the span
211+
expect(response.length).toEqual(2);
212+
expect(response).toContain({startLine: 6, endLine: 9});
213+
expect(response).toContain({startLine: 7, endLine: 8});
214+
});
215+
184216
describe('signature help', () => {
185217
it('should show signature help for an empty call', async () => {
186218
client.sendNotification(lsp.DidOpenTextDocumentNotification.type, {

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@
248248
"tslint": "6.1.3",
249249
"tslint-eslint-rules": "5.4.0",
250250
"vsce": "1.100.1",
251+
"vscode-html-languageservice": "^5.0.2",
251252
"vscode-languageserver-protocol": "3.16.0",
252253
"vscode-languageserver-textdocument": "1.0.7",
253254
"vscode-test": "1.6.1",
@@ -257,4 +258,4 @@
257258
"type": "git",
258259
"url": "https://github.com/angular/vscode-ng-language-service"
259260
}
260-
}
261+
}

server/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ esbuild(
5353
"vscode-languageserver",
5454
"vscode-uri",
5555
"vscode-jsonrpc",
56+
"vscode-languageserver-textdocument",
57+
"vscode-html-languageservice",
5658
],
5759
config = "esbuild.mjs",
5860
# Do not enable minification. It seems to break the extension on Windows (with WSL). See #1198.

server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
},
1717
"dependencies": {
1818
"@angular/language-service": "15.0.0-next.0",
19+
"vscode-html-languageservice": "^5.0.2",
1920
"vscode-jsonrpc": "6.0.0",
2021
"vscode-languageserver": "7.0.0",
22+
"vscode-languageserver-textdocument": "^1.0.7",
2123
"vscode-uri": "3.0.3"
2224
},
2325
"publishConfig": {
2426
"registry": "https://wombat-dressing-room.appspot.com"
2527
}
26-
}
28+
}

server/src/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ ts_project(
1111
"//:node_modules/@angular/language-service",
1212
"//:node_modules/@types/node",
1313
"//:node_modules/typescript",
14+
"//:node_modules/vscode-html-languageservice",
1415
"//:node_modules/vscode-languageserver",
16+
"//:node_modules/vscode-languageserver-textdocument",
1517
"//:node_modules/vscode-uri",
1618
"//common",
1719
],

server/src/embedded_support.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
/**
12+
* Takes a TS file and strips out all non-inline template content.
13+
*
14+
* This process is the same as what's done in the VSCode example for embedded languages.
15+
*
16+
* Note that the example below implements the support on the client side. This is done on the server
17+
* side to enable language services in other editors to take advantage of this feature by depending
18+
* on the @angular/language-server package.
19+
*
20+
* @see https://github.com/microsoft/vscode-extension-samples/blob/fdd3bb95ce8e38ffe58fc9158797239fdf5017f1/lsp-embedded-request-forwarding/client/src/embeddedSupport.ts#L131-L141
21+
* @see https://code.visualstudio.com/api/language-extensions/embedded-languages
22+
*/
23+
export function getHTMLVirtualContent(documentText: string): string {
24+
const sf =
25+
ts.createSourceFile('temp', documentText, ts.ScriptTarget.ESNext, true /* setParentNodes */);
26+
const inlineTemplateNodes: ts.Node[] = findAllMatchingNodes(sf, isInlineTemplateNode);
27+
28+
// Create a blank document with same text length
29+
let content = documentText.split('\n')
30+
.map(line => {
31+
return ' '.repeat(line.length);
32+
})
33+
.join('\n');
34+
35+
// add back all the inline template regions in-place
36+
inlineTemplateNodes.forEach(r => {
37+
content = content.slice(0, r.getStart(sf) + 1) +
38+
documentText.slice(r.getStart(sf) + 1, r.getEnd() - 1) + content.slice(r.getEnd() - 1);
39+
});
40+
return content;
41+
}
42+
43+
function isInlineTemplateNode(node: ts.Node) {
44+
const assignment = getPropertyAssignmentFromValue(node, 'template');
45+
return ts.isStringLiteralLike(node) && assignment !== null &&
46+
getClassDeclFromDecoratorProp(assignment) !== null;
47+
}
48+
49+
/**
50+
* Returns a property assignment from the assignment value if the property name
51+
* matches the specified `key`, or `null` if there is no match.
52+
*/
53+
export function getPropertyAssignmentFromValue(value: ts.Node, key: string): ts.PropertyAssignment|
54+
null {
55+
const propAssignment = value.parent;
56+
if (!propAssignment || !ts.isPropertyAssignment(propAssignment) ||
57+
propAssignment.name.getText() !== key) {
58+
return null;
59+
}
60+
return propAssignment;
61+
}
62+
63+
/**
64+
* Given a decorator property assignment, return the ClassDeclaration node that corresponds to the
65+
* directive class the property applies to.
66+
* If the property assignment is not on a class decorator, no declaration is returned.
67+
*
68+
* For example,
69+
*
70+
* @Component({
71+
* template: '<div></div>'
72+
* ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment
73+
* })
74+
* class AppComponent {}
75+
* ^---- class declaration node
76+
*
77+
* @param propAsgnNode property assignment
78+
*/
79+
export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignment):
80+
ts.ClassDeclaration|undefined {
81+
if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) {
82+
return;
83+
}
84+
const objLitExprNode = propAsgnNode.parent;
85+
if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) {
86+
return;
87+
}
88+
const callExprNode = objLitExprNode.parent;
89+
if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) {
90+
return;
91+
}
92+
const decorator = callExprNode.parent;
93+
if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) {
94+
return;
95+
}
96+
const classDeclNode = decorator.parent;
97+
return classDeclNode;
98+
}
99+
100+
export function findAllMatchingNodes(
101+
sf: ts.SourceFile, filter: (node: ts.Node) => boolean): ts.Node[] {
102+
const results: ts.Node[] = [];
103+
const stack: ts.Node[] = [sf];
104+
105+
while (stack.length > 0) {
106+
const node = stack.pop()!;
107+
108+
if (filter(node)) {
109+
results.push(node);
110+
} else {
111+
stack.push(...node.getChildren());
112+
}
113+
}
114+
115+
return results;
116+
}

server/src/session.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api';
1010
import * as ts from 'typescript/lib/tsserverlibrary';
1111
import {promisify} from 'util';
12+
import {getLanguageService as getHTMLLanguageService} from 'vscode-html-languageservice';
13+
import {TextDocument} from 'vscode-languageserver-textdocument';
1214
import * as lsp from 'vscode-languageserver/node';
1315

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

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

57+
const htmlLS = getHTMLLanguageService();
58+
5459
/**
5560
* Session is a wrapper around lsp.IConnection, with all the necessary protocol
5661
* handlers installed for Angular language service.
@@ -190,6 +195,7 @@ export class Session {
190195
conn.onRenameRequest(p => this.onRenameRequest(p));
191196
conn.onPrepareRename(p => this.onPrepareRename(p));
192197
conn.onHover(p => this.onHover(p));
198+
conn.onFoldingRanges(p => this.onFoldingRanges(p));
193199
conn.onCompletion(p => this.onCompletion(p));
194200
conn.onCompletionResolve(p => this.onCompletionResolve(p));
195201
conn.onRequest(GetComponentsWithTemplateFile, p => this.onGetComponentsWithTemplateFile(p));
@@ -710,6 +716,7 @@ export class Session {
710716
this.clientCapabilities = params.capabilities;
711717
return {
712718
capabilities: {
719+
foldingRangeProvider: true,
713720
codeLensProvider: this.ivy ? {resolveProvider: true} : undefined,
714721
textDocumentSync: lsp.TextDocumentSyncKind.Incremental,
715722
completionProvider: {
@@ -854,6 +861,23 @@ export class Session {
854861
}
855862
}
856863

864+
private onFoldingRanges(params: lsp.FoldingRangeParams) {
865+
if (!params.textDocument.uri?.endsWith('ts')) {
866+
return null;
867+
}
868+
869+
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
870+
if (lsInfo === null) {
871+
return;
872+
}
873+
const {scriptInfo} = lsInfo;
874+
const docText = scriptInfo.getSnapshot().getText(0, scriptInfo.getSnapshot().getLength());
875+
const virtualHtmlDocContents = getHTMLVirtualContent(docText);
876+
const virtualHtmlDoc =
877+
TextDocument.create(params.textDocument.uri.toString(), 'html', 0, virtualHtmlDocContents);
878+
return htmlLS.getFoldingRanges(virtualHtmlDoc);
879+
}
880+
857881
private onDefinition(params: lsp.TextDocumentPositionParams): lsp.LocationLink[]|null {
858882
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
859883
if (lsInfo === null) {

yarn.lock

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7573,6 +7573,16 @@ [email protected]:
75737573
yauzl "^2.3.1"
75747574
yazl "^2.2.2"
75757575

7576+
vscode-html-languageservice@^5.0.2:
7577+
version "5.0.2"
7578+
resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-5.0.2.tgz#a66cb9d779f3094a8d14dd3a8f7935748435fd2a"
7579+
integrity sha512-TQmeyE14Ure/w/S+RV2IItuRWmw/i1QaS+om6t70iHCpamuTTWnACQPMSltVGm/DlbdyMquUePJREjd/h3AVkQ==
7580+
dependencies:
7581+
vscode-languageserver-textdocument "^1.0.7"
7582+
vscode-languageserver-types "^3.17.2"
7583+
vscode-nls "^5.2.0"
7584+
vscode-uri "^3.0.4"
7585+
75767586
75777587
version "6.0.0"
75787588
resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz#108bdb09b4400705176b957ceca9e0880e9b6d4e"
@@ -7595,7 +7605,7 @@ [email protected]:
75957605
vscode-jsonrpc "6.0.0"
75967606
vscode-languageserver-types "3.16.0"
75977607

7598-
7608+
[email protected], vscode-languageserver-textdocument@^1.0.7:
75997609
version "1.0.7"
76007610
resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.7.tgz#16df468d5c2606103c90554ae05f9f3d335b771b"
76017611
integrity sha512-bFJH7UQxlXT8kKeyiyu41r22jCZXG8kuuVVA33OEJn1diWOZK5n8zBSPZFHVBOu8kXZ6h0LIRhf5UnCo61J4Hg==
@@ -7605,13 +7615,23 @@ [email protected]:
76057615
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz#ecf393fc121ec6974b2da3efb3155644c514e247"
76067616
integrity sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==
76077617

7618+
vscode-languageserver-types@^3.17.2:
7619+
version "3.17.2"
7620+
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz#b2c2e7de405ad3d73a883e91989b850170ffc4f2"
7621+
integrity sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA==
7622+
76087623
76097624
version "7.0.0"
76107625
resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-7.0.0.tgz#49b068c87cfcca93a356969d20f5d9bdd501c6b0"
76117626
integrity sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==
76127627
dependencies:
76137628
vscode-languageserver-protocol "3.16.0"
76147629

7630+
vscode-nls@^5.2.0:
7631+
version "5.2.0"
7632+
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.2.0.tgz#3cb6893dd9bd695244d8a024bdf746eea665cc3f"
7633+
integrity sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==
7634+
76157635
vscode-oniguruma@^1.5.1:
76167636
version "1.5.1"
76177637
resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.5.1.tgz#9ca10cd3ada128bd6380344ea28844243d11f695"
@@ -7649,6 +7669,11 @@ [email protected]:
76497669
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84"
76507670
integrity sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA==
76517671

7672+
vscode-uri@^3.0.4:
7673+
version "3.0.6"
7674+
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.6.tgz#5e6e2e1a4170543af30151b561a41f71db1d6f91"
7675+
integrity sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ==
7676+
76527677
watchpack@^2.3.1:
76537678
version "2.3.1"
76547679
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"

0 commit comments

Comments
 (0)