diff --git a/.vscode/launch.json b/.vscode/launch.json index 9f5e46f86c..67b394597a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,10 +9,10 @@ "runtimeExecutable": "${execPath}", "args": [ "--disable-extensions", - "--extensionDevelopmentPath=${workspaceRoot}" + "--extensionDevelopmentPath=${workspaceFolder}" ], "outFiles": [ - "${workspaceRoot}/dist/client/*.js" + "${workspaceFolder}/dist/client/*.js" ], "preLaunchTask": { "type": "npm", @@ -26,9 +26,19 @@ "port": 6009, "restart": true, "outFiles": [ - "${workspaceRoot}/dist/server/*.js" + "${workspaceFolder}/dist/server/*.js" ] }, + { + "type": "node", + "request": "attach", + "name": "Attach to Jasmine", + "port": 9229, + "restart": true, + "outFiles": [ + "${workspaceFolder}/dist/**/*.js" + ], + }, { "name": "Integration test: Attach to server", "port": 9330, @@ -37,7 +47,7 @@ "/**" ], "outFiles": [ - "${workspaceRoot}/dist/integration/lsp/*.js" + "${workspaceFolder}/dist/integration/lsp/*.js" ], "type": "node" }, @@ -47,12 +57,12 @@ "request": "launch", "runtimeExecutable": "${execPath}", "args": [ - "--extensionDevelopmentPath=${workspaceRoot}", - "--extensionTestsPath=${workspaceRoot}/dist/integration/e2e", - "${workspaceRoot}/integration/project" + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/dist/integration/e2e", + "${workspaceFolder}/integration/project" ], "outFiles": [ - "${workspaceRoot}/dist/integration/e2e/*.js" + "${workspaceFolder}/dist/integration/e2e/*.js" ], "preLaunchTask": { "type": "npm", diff --git a/client/src/client.ts b/client/src/client.ts index 90eb2cd04c..3f40919b27 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -15,6 +15,7 @@ import {ProjectLoadingFinish, ProjectLoadingStart, SuggestIvyLanguageService, Su import {NgccProgress, NgccProgressToken, NgccProgressType} from '../common/progress'; import {GetTcbRequest} from '../common/requests'; +import {isInsideComponentDecorator, isInsideInlineTemplateRegion} from './embedded_support'; import {ProgressReporter} from './progress-reporter'; interface GetTcbResponse { @@ -50,6 +51,37 @@ export class AngularLanguageClient implements vscode.Disposable { // Don't let our output console pop open revealOutputChannelOn: lsp.RevealOutputChannelOn.Never, outputChannel: this.outputChannel, + middleware: { + provideDefinition: async ( + document: vscode.TextDocument, position: vscode.Position, + token: vscode.CancellationToken, next: lsp.ProvideDefinitionSignature) => { + if (isInsideComponentDecorator(document, position)) { + return next(document, position, token); + } + }, + provideTypeDefinition: async ( + document: vscode.TextDocument, position: vscode.Position, + token: vscode.CancellationToken, next) => { + if (isInsideInlineTemplateRegion(document, position)) { + return next(document, position, token); + } + }, + provideHover: async ( + document: vscode.TextDocument, position: vscode.Position, + token: vscode.CancellationToken, next: lsp.ProvideHoverSignature) => { + if (isInsideInlineTemplateRegion(document, position)) { + return next(document, position, token); + } + }, + provideCompletionItem: async ( + document: vscode.TextDocument, position: vscode.Position, + context: vscode.CompletionContext, token: vscode.CancellationToken, + next: lsp.ProvideCompletionItemsSignature) => { + if (isInsideInlineTemplateRegion(document, position)) { + return next(document, position, context, token); + } + } + } }; } @@ -312,4 +344,4 @@ function getServerOptions(ctx: vscode.ExtensionContext, debug: boolean): lsp.Nod execArgv: debug ? devExecArgv : prodExecArgv, }, }; -} +} \ No newline at end of file diff --git a/client/src/embedded_support.ts b/client/src/embedded_support.ts new file mode 100644 index 0000000000..25cffa7176 --- /dev/null +++ b/client/src/embedded_support.ts @@ -0,0 +1,94 @@ +/** + * @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'; +import * as vscode from 'vscode'; + +/** Determines if the position is inside an inline template. */ +export function isInsideInlineTemplateRegion( + document: vscode.TextDocument, position: vscode.Position): boolean { + if (document.languageId !== 'typescript') { + return true; + } + return isPropertyAssignmentToStringOrStringInArray( + document.getText(), document.offsetAt(position), ['template']); +} + +/** Determines if the position is inside an inline template, templateUrl, or string in styleUrls. */ +export function isInsideComponentDecorator( + document: vscode.TextDocument, position: vscode.Position): boolean { + if (document.languageId !== 'typescript') { + return true; + } + return isPropertyAssignmentToStringOrStringInArray( + document.getText(), document.offsetAt(position), ['template', 'templateUrl', 'styleUrls']); +} + +/** + * Basic scanner to determine if we're inside a string of a property with one of the given names. + * + * This scanner is not currently robust or perfect but provides us with an accurate answer _most_ of + * the time. + * + * False positives are OK here. Though this will give some false positives for determining if a + * position is within an Angular context, i.e. an object like `{template: ''}` that is not inside an + * `@Component` or `{styleUrls: [someFunction('stringL¦iteral')]}`, the @angular/language-service + * will always give us the correct answer. This helper gives us a quick win for optimizing the + * number of requests we send to the server. + */ +function isPropertyAssignmentToStringOrStringInArray( + documentText: string, offset: number, propertyAssignmentNames: string[]): boolean { + const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true /* skipTrivia */); + scanner.setText(documentText); + + let token: ts.SyntaxKind = scanner.scan(); + let lastToken: ts.SyntaxKind|undefined; + let lastTokenText: string|undefined; + let unclosedBraces = 0; + let unclosedBrackets = 0; + let propertyAssignmentContext = false; + while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getStartPos() < offset) { + if (lastToken === ts.SyntaxKind.Identifier && lastTokenText !== undefined && + propertyAssignmentNames.includes(lastTokenText) && token === ts.SyntaxKind.ColonToken) { + propertyAssignmentContext = true; + token = scanner.scan(); + continue; + } + if (unclosedBraces === 0 && unclosedBrackets === 0 && isPropertyAssignmentTerminator(token)) { + propertyAssignmentContext = false; + } + + if (token === ts.SyntaxKind.OpenBracketToken) { + unclosedBrackets++; + } else if (token === ts.SyntaxKind.OpenBraceToken) { + unclosedBraces++; + } else if (token === ts.SyntaxKind.CloseBracketToken) { + unclosedBrackets--; + } else if (token === ts.SyntaxKind.CloseBraceToken) { + unclosedBraces--; + } + + const isStringToken = token === ts.SyntaxKind.StringLiteral || + token === ts.SyntaxKind.NoSubstitutionTemplateLiteral; + const isCursorInToken = scanner.getStartPos() <= offset && + scanner.getStartPos() + scanner.getTokenText().length >= offset; + if (propertyAssignmentContext && isCursorInToken && isStringToken) { + return true; + } + + lastTokenText = scanner.getTokenText(); + lastToken = token; + token = scanner.scan(); + } + + return false; +} + +function isPropertyAssignmentTerminator(token: ts.SyntaxKind) { + return token === ts.SyntaxKind.EndOfFileToken || token === ts.SyntaxKind.CommaToken || + token === ts.SyntaxKind.SemicolonToken || token === ts.SyntaxKind.CloseBraceToken; +} \ No newline at end of file diff --git a/client/src/tests/embedded_support_spec.ts b/client/src/tests/embedded_support_spec.ts new file mode 100644 index 0000000000..e96c422d8b --- /dev/null +++ b/client/src/tests/embedded_support_spec.ts @@ -0,0 +1,115 @@ +/** + * @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'; +import {DocumentUri, TextDocument} from 'vscode-languageserver-textdocument'; + +import {isInsideComponentDecorator, isInsideInlineTemplateRegion} from '../embedded_support'; + +describe('embedded language support', () => { + describe('isInsideInlineTemplateRegion', () => { + it('empty file', () => { + test('¦', isInsideInlineTemplateRegion, false); + }); + + it('just after template', () => { + test(`template: '
'¦`, isInsideInlineTemplateRegion, false); + }); + + it('just before template', () => { + // Note that while it seems that this should be `false`, we should still consider this inside + // the string because the visual mode of vim appears to have a position on top of the open + // quote while the cursor position is before it. + test(`template: ¦'
'`, isInsideInlineTemplateRegion, true); + }); + + it('two spaces before template', () => { + test(`template:¦ '
'`, isInsideInlineTemplateRegion, false); + }); + + it('at beginning of template', () => { + test(`template: '¦
'`, isInsideInlineTemplateRegion, true); + }); + + it('at end of template', () => { + test(`template: '
¦'`, isInsideInlineTemplateRegion, true); + }); + }); + + describe('isInsideAngularContext', () => { + it('empty file', () => { + test('¦', isInsideComponentDecorator, false); + }); + + it('just after template', () => { + test(`template: '
'¦`, isInsideComponentDecorator, false); + }); + + it('inside template', () => { + test(`template: '
¦
'`, isInsideComponentDecorator, true); + }); + + it('just after templateUrl', () => { + test(`templateUrl: './abc.html'¦`, isInsideComponentDecorator, false); + }); + + it('inside templateUrl', () => { + test(`templateUrl: './abc¦.html'`, isInsideComponentDecorator, true); + }); + + it('just after styleUrls', () => { + test(`styleUrls: ['./abc.css']¦`, isInsideComponentDecorator, false); + }); + + it('inside first item of styleUrls', () => { + test(`styleUrls: ['./abc.c¦ss', 'def.css']`, isInsideComponentDecorator, true); + }); + + it('inside second item of styleUrls', () => { + test(`styleUrls: ['./abc.css', 'def¦.css']`, isInsideComponentDecorator, true); + }); + + it('inside second item of styleUrls, when first is complicated function', () => { + test( + `styleUrls: [getCss({strict: true, dirs: ['apple', 'banana']}), 'def¦.css']`, + isInsideComponentDecorator, true); + }); + + it('inside non-string item of styleUrls', () => { + test( + `styleUrls: [getCss({strict: true¦, dirs: ['apple', 'banana']}), 'def.css']`, + isInsideComponentDecorator, false); + }); + }); +}); + +function test( + fileWithCursor: string, + testFn: (doc: vscode.TextDocument, position: vscode.Position) => boolean, + expectation: boolean): void { + const {cursor, text} = extractCursorInfo(fileWithCursor); + const vdoc = TextDocument.create('test' as DocumentUri, 'typescript', 0, text) as {} as + vscode.TextDocument; + const actual = testFn(vdoc, vdoc.positionAt(cursor)); + expect(actual).toBe(expectation); +} + +/** + * Given a text snippet which contains exactly one cursor symbol ('¦'), extract both the offset of + * that cursor within the text as well as the text snippet without the cursor. + */ +function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} { + const cursor = textWithCursor.indexOf('¦'); + if (cursor === -1 || textWithCursor.indexOf('¦', cursor + 1) !== -1) { + throw new Error(`Expected to find exactly one cursor symbol '¦'`); + } + + return { + cursor, + text: textWithCursor.substr(0, cursor) + textWithCursor.substr(cursor + 1), + }; +} \ No newline at end of file diff --git a/client/src/tests/tsconfig.json b/client/src/tests/tsconfig.json new file mode 100644 index 0000000000..167e4aff1c --- /dev/null +++ b/client/src/tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/client/tests" + }, + "references": [ + { + "path": "../../tsconfig.json" + } + ], + "include": [ + "*.ts" + ] +} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json index cd81101d08..80255dc8c5 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,12 +1,23 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "composite": true, "outDir": "../dist/client", - "rootDirs": [".", "../dist"] + "rootDir": "src", + "rootDirs": [ + ".", + "../dist" + ] }, "references": [ - {"path": "../common"} + { + "path": "../common" + } ], - "include": ["src"], - "exclude": ["node_modules"] -} + "include": [ + "src" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/jasmine.json b/jasmine.json new file mode 100644 index 0000000000..c19fcf1738 --- /dev/null +++ b/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "dist", + "spec_files": [ + "client/tests/*_spec.js", + "server/tests/*_spec.js" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 4df3f3ac4a..8cdfe77f32 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "main": "./dist/client/extension", "scripts": { "compile": "tsc -p server/banner.tsconfig.json && tsc -b && node esbuild.js && rollup -c", - "compile:test": "tsc -b server/src/tests", + "compile:test": "tsc -b server/src/tests && tsc -b client/src/tests", "compile:integration": "tsc -b integration", "compile:syntaxes-test": "tsc -b syntaxes/test", "build:syntaxes": "tsc -b syntaxes && node dist/syntaxes/build.js", @@ -145,7 +145,8 @@ "watch": "tsc -b -w", "postinstall": "vscode-install", "package": "rm -rf dist && node scripts/package.js", - "test": "yarn compile:test && jasmine dist/server/tests/*_spec.js", + "test": "yarn compile:test && jasmine --config=jasmine.json", + "test:inspect": "yarn compile:test && node --inspect-brk node_modules/jasmine/bin/jasmine.js --config=jasmine.json", "test:lsp": "yarn compile:integration && jasmine --config=integration/lsp/jasmine.json", "test:e2e": "yarn compile:integration && ./scripts/e2e.sh", "test:syntaxes": "yarn compile:syntaxes-test && yarn build:syntaxes && jasmine dist/syntaxes/test/driver.js" @@ -172,6 +173,7 @@ "vsce": "1.85.0", "vscode": "1.1.37", "vscode-languageserver-protocol": "3.16.0", + "vscode-languageserver-textdocument": "^1.0.1", "vscode-tmgrammar-test": "0.0.10" }, "repository": { diff --git a/yarn.lock b/yarn.lock index af2d7d6c7a..079362c01f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -993,6 +993,11 @@ vscode-languageserver-protocol@3.16.0: vscode-jsonrpc "6.0.0" vscode-languageserver-types "3.16.0" +vscode-languageserver-textdocument@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz#178168e87efad6171b372add1dea34f53e5d330f" + integrity sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA== + vscode-languageserver-types@3.16.0: version "3.16.0" resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz#ecf393fc121ec6974b2da3efb3155644c514e247"