From 3f257e96c65b5024e5080f2e2d4b67373f5639e6 Mon Sep 17 00:00:00 2001 From: Keen Yee Liau Date: Thu, 17 Oct 2019 18:42:27 -0700 Subject: [PATCH] fix(language-service): Add global symbol for $any() (#33245) This commit introduces a "global symbol table" in the language service for symbols that are available in the top level scope, and add `$any()` to it. See https://angular.io/guide/template-syntax#the-any-type-cast-function PR closes https://github.com/angular/vscode-ng-language-service/issues/242 PR Close #33245 --- .../language-service/src/global_symbols.ts | 63 +++++++++++++++++++ packages/language-service/src/template.ts | 7 ++- packages/language-service/test/BUILD.bazel | 2 +- .../language-service/test/completions_spec.ts | 6 ++ .../language-service/test/diagnostics_spec.ts | 21 +++++++ .../test/global_symbols_spec.ts | 28 +++++++++ packages/language-service/test/hover_spec.ts | 15 +++++ 7 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 packages/language-service/src/global_symbols.ts create mode 100644 packages/language-service/test/global_symbols_spec.ts diff --git a/packages/language-service/src/global_symbols.ts b/packages/language-service/src/global_symbols.ts new file mode 100644 index 0000000000..5388916862 --- /dev/null +++ b/packages/language-service/src/global_symbols.ts @@ -0,0 +1,63 @@ +/** + * @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 ng from '../src/types'; + +export const EMPTY_SYMBOL_TABLE: Readonly = { + size: 0, + get: () => undefined, + has: () => false, + values: () => [], +}; + +/** + * A factory function that returns a symbol table that contains all global symbols + * available in an interpolation scope in a template. + * This function creates the table the first time it is called, and return a cached + * value for all subsequent calls. + */ +export const createGlobalSymbolTable: (query: ng.SymbolQuery) => ng.SymbolTable = (function() { + let GLOBAL_SYMBOL_TABLE: ng.SymbolTable|undefined; + return function(query: ng.SymbolQuery) { + if (GLOBAL_SYMBOL_TABLE) { + return GLOBAL_SYMBOL_TABLE; + } + GLOBAL_SYMBOL_TABLE = query.createSymbolTable([ + // The `$any()` method casts the type of an expression to `any`. + // https://angular.io/guide/template-syntax#the-any-type-cast-function + { + name: '$any', + kind: 'method', + type: { + name: '$any', + kind: 'method', + type: undefined, + language: 'typescript', + container: undefined, + public: true, + callable: true, + definition: undefined, + nullable: false, + members: () => EMPTY_SYMBOL_TABLE, + signatures: () => [], + selectSignature(args: ng.Symbol[]) { + if (args.length !== 1) { + return; + } + return { + arguments: EMPTY_SYMBOL_TABLE, // not used + result: query.getBuiltinType(ng.BuiltinType.Any), + }; + }, + indexed: () => undefined, + }, + }, + ]); + return GLOBAL_SYMBOL_TABLE; + }; +})(); diff --git a/packages/language-service/src/template.ts b/packages/language-service/src/template.ts index a8642f18b9..d40a7227d1 100644 --- a/packages/language-service/src/template.ts +++ b/packages/language-service/src/template.ts @@ -10,6 +10,7 @@ import {getClassMembersFromDeclaration, getPipesTable, getSymbolQuery} from '@an import * as ts from 'typescript'; import {isAstResult} from './common'; +import {createGlobalSymbolTable} from './global_symbols'; import * as ng from './types'; import {TypeScriptServiceHost} from './typescript_host'; @@ -48,8 +49,10 @@ abstract class BaseTemplate implements ng.TemplateSource { if (!this.membersTable) { const typeChecker = this.program.getTypeChecker(); const sourceFile = this.classDeclNode.getSourceFile(); - this.membersTable = - getClassMembersFromDeclaration(this.program, typeChecker, sourceFile, this.classDeclNode); + this.membersTable = this.query.mergeSymbolTable([ + createGlobalSymbolTable(this.query), + getClassMembersFromDeclaration(this.program, typeChecker, sourceFile, this.classDeclNode), + ]); } return this.membersTable; } diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel index 0838517175..81cd6f4e5b 100644 --- a/packages/language-service/test/BUILD.bazel +++ b/packages/language-service/test/BUILD.bazel @@ -11,6 +11,7 @@ ts_library( deps = [ "//packages:types", "//packages/compiler", + "//packages/compiler-cli", "//packages/compiler-cli/test:test_utils", "//packages/language-service", "@npm//typescript", @@ -21,7 +22,6 @@ jasmine_node_test( name = "test", data = [ "//packages/common:npm_package", - "//packages/compiler:npm_package", "//packages/core:npm_package", "//packages/forms:npm_package", ], diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index b5177bc132..2ae39da15e 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -153,6 +153,12 @@ describe('completions', () => { expectContain(completions, CompletionKind.PROPERTY, ['title', 'subTitle']); }); + it('should suggest $any() type cast function in an interpolation', () => { + const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'sub-start'); + const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); + expectContain(completions, CompletionKind.METHOD, ['$any']); + }); + describe('in external template', () => { it('should be able to get entity completions in external template', () => { const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'entity-amp'); diff --git a/packages/language-service/test/diagnostics_spec.ts b/packages/language-service/test/diagnostics_spec.ts index 1db4fa0b84..8672897401 100644 --- a/packages/language-service/test/diagnostics_spec.ts +++ b/packages/language-service/test/diagnostics_spec.ts @@ -26,6 +26,7 @@ import {MockTypescriptHost} from './test_utils'; const EXPRESSION_CASES = '/app/expression-cases.ts'; const NG_FOR_CASES = '/app/ng-for-cases.ts'; const NG_IF_CASES = '/app/ng-if-cases.ts'; +const TEST_TEMPLATE = '/app/test.ng'; describe('diagnostics', () => { const mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts']); @@ -55,6 +56,26 @@ describe('diagnostics', () => { } }); + // https://github.com/angular/vscode-ng-language-service/issues/242 + it('should support $any() type cast function', () => { + mockHost.override(TEST_TEMPLATE, `
{{$any(title).xyz}}
`); + const diags = ngLS.getDiagnostics(TEST_TEMPLATE); + expect(diags).toEqual([]); + }); + + it('should report error for $any() with incorrect number of arguments', () => { + const templates = [ + '
{{$any().xyz}}
', // no argument + '
{{$any(title, title).xyz}}
', // two arguments + ]; + for (const template of templates) { + mockHost.override(TEST_TEMPLATE, template); + const diags = ngLS.getDiagnostics(TEST_TEMPLATE); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe('Unable to resolve signature for call of method $any'); + } + }); + describe('in expression-cases.ts', () => { it('should report access to an unknown field', () => { const diags = ngLS.getDiagnostics(EXPRESSION_CASES).map(d => d.messageText); diff --git a/packages/language-service/test/global_symbols_spec.ts b/packages/language-service/test/global_symbols_spec.ts new file mode 100644 index 0000000000..9dea6d8aba --- /dev/null +++ b/packages/language-service/test/global_symbols_spec.ts @@ -0,0 +1,28 @@ +/** + * @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 {getSymbolQuery} from '@angular/compiler-cli'; +import * as ts from 'typescript/lib/tsserverlibrary'; + +import {EMPTY_SYMBOL_TABLE, createGlobalSymbolTable} from '../src/global_symbols'; + +import {MockTypescriptHost} from './test_utils'; + +describe('GlobalSymbolTable', () => { + const mockHost = new MockTypescriptHost([]); + const tsLS = ts.createLanguageService(mockHost); + + it(`contains $any()`, () => { + const program = tsLS.getProgram() !; + const typeChecker = program.getTypeChecker(); + const source = ts.createSourceFile('foo.ts', '', ts.ScriptTarget.ES2015); + const query = getSymbolQuery(program, typeChecker, source, () => EMPTY_SYMBOL_TABLE); + const table = createGlobalSymbolTable(query); + expect(table.has('$any')).toBe(true); + }); +}); diff --git a/packages/language-service/test/hover_spec.ts b/packages/language-service/test/hover_spec.ts index a192c0fbf2..e47263373c 100644 --- a/packages/language-service/test/hover_spec.ts +++ b/packages/language-service/test/hover_spec.ts @@ -13,6 +13,8 @@ import {TypeScriptServiceHost} from '../src/typescript_host'; import {MockTypescriptHost} from './test_utils'; +const TEST_TEMPLATE = '/app/test.ng'; + describe('hover', () => { const mockHost = new MockTypescriptHost(['/app/main.ts']); const tsLS = ts.createLanguageService(mockHost); @@ -190,6 +192,19 @@ describe('hover', () => { }); expect(toText(displayParts)).toBe('(directive) AppModule.StringModel: class'); }); + + it('should be able to provide quick info for $any() cast function', () => { + const content = mockHost.override(TEST_TEMPLATE, '
{{$any(title)}}
'); + const position = content.indexOf('$any'); + const quickInfo = ngLS.getHoverAt(TEST_TEMPLATE, position); + expect(quickInfo).toBeDefined(); + const {textSpan, displayParts} = quickInfo !; + expect(textSpan).toEqual({ + start: position, + length: '$any(title)'.length, + }); + expect(toText(displayParts)).toBe('(method) $any'); + }); }); function toText(displayParts?: ts.SymbolDisplayPart[]): string {