diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index c595c8db1a..4e9a89b263 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -279,7 +279,7 @@ function voidElementAttributeCompletions(info: TemplateInfo, path: AstPath SymbolTable; - result: Completions; + result: Completion[]|undefined; constructor( private info: TemplateInfo, private position: number, private attr?: Attribute, diff --git a/packages/language-service/src/definitions.ts b/packages/language-service/src/definitions.ts index 76a6e0a1be..ae3e0cc241 100644 --- a/packages/language-service/src/definitions.ts +++ b/packages/language-service/src/definitions.ts @@ -6,11 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ +import * as tss from 'typescript/lib/tsserverlibrary'; + import {TemplateInfo} from './common'; import {locateSymbol} from './locate_symbol'; -import {Definition} from './types'; +import {Location} from './types'; -export function getDefinition(info: TemplateInfo): Definition { +export function getDefinition(info: TemplateInfo): Location[]|undefined { const result = locateSymbol(info); return result && result.symbol.definition; } + +export function ngLocationToTsDefinitionInfo(loc: Location): tss.DefinitionInfo { + return { + fileName: loc.fileName, + textSpan: { + start: loc.span.start, + length: loc.span.end - loc.span.start, + }, + // TODO(kyliau): Provide more useful info for name, kind and containerKind + name: '', // should be name of symbol but we don't have enough information here. + kind: tss.ScriptElementKind.unknown, + containerName: loc.fileName, + containerKind: tss.ScriptElementKind.unknown, + }; +} diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index fb2754eda2..b0a341cb5f 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -13,7 +13,7 @@ import {getTemplateCompletions} from './completions'; import {getDefinition} from './definitions'; import {getDeclarationDiagnostics} from './diagnostics'; import {getHover} from './hover'; -import {Completions, Definition, Diagnostic, DiagnosticKind, Diagnostics, Hover, LanguageService, LanguageServiceHost, Span, TemplateSource} from './types'; +import {Completion, Diagnostic, DiagnosticKind, Diagnostics, Hover, LanguageService, LanguageServiceHost, Location, Span, TemplateSource} from './types'; import {offsetSpan, spanOf} from './utils'; @@ -34,14 +34,14 @@ class LanguageServiceImpl implements LanguageService { getTemplateReferences(): string[] { return this.host.getTemplateReferences(); } - getDiagnostics(fileName: string): Diagnostics|undefined { - let results: Diagnostics = []; - let templates = this.host.getTemplates(fileName); + getDiagnostics(fileName: string): Diagnostic[] { + const results: Diagnostic[] = []; + const templates = this.host.getTemplates(fileName); if (templates && templates.length) { results.push(...this.getTemplateDiagnostics(fileName, templates)); } - let declarations = this.host.getDeclarations(fileName); + const declarations = this.host.getDeclarations(fileName); if (declarations && declarations.length) { const summary = this.host.getAnalyzedModules(); results.push(...getDeclarationDiagnostics(declarations, summary)); @@ -58,14 +58,14 @@ class LanguageServiceImpl implements LanguageService { return []; } - getCompletionsAt(fileName: string, position: number): Completions { + getCompletionsAt(fileName: string, position: number): Completion[]|undefined { let templateInfo = this.host.getTemplateAstAtPosition(fileName, position); if (templateInfo) { return getTemplateCompletions(templateInfo); } } - getDefinitionAt(fileName: string, position: number): Definition { + getDefinitionAt(fileName: string, position: number): Location[]|undefined { let templateInfo = this.host.getTemplateAstAtPosition(fileName, position); if (templateInfo) { return getDefinition(templateInfo); @@ -112,25 +112,20 @@ class LanguageServiceImpl implements LanguageService { } } -function uniqueBySpan < T extends { - span: Span; -} -> (elements: T[] | undefined): T[]|undefined { - if (elements) { - const result: T[] = []; - const map = new Map>(); - for (const element of elements) { - let span = element.span; - let set = map.get(span.start); - if (!set) { - set = new Set(); - map.set(span.start, set); - } - if (!set.has(span.end)) { - set.add(span.end); - result.push(element); - } +function uniqueBySpan(elements: T[]): T[] { + const result: T[] = []; + const map = new Map>(); + for (const element of elements) { + const {span} = element; + let set = map.get(span.start); + if (!set) { + set = new Set(); + map.set(span.start, set); + } + if (!set.has(span.end)) { + set.add(span.end); + result.push(element); } - return result; } + return result; } diff --git a/packages/language-service/src/ts_plugin.ts b/packages/language-service/src/ts_plugin.ts index e89de93f38..eb41009748 100644 --- a/packages/language-service/src/ts_plugin.ts +++ b/packages/language-service/src/ts_plugin.ts @@ -9,6 +9,7 @@ import * as ts from 'typescript'; // used as value, passed in by tsserver at runtime import * as tss from 'typescript/lib/tsserverlibrary'; // used as type only +import {ngLocationToTsDefinitionInfo} from './definitions'; import {createLanguageService} from './language_service'; import {Completion, Diagnostic, DiagnosticMessageChain, Location} from './types'; import {TypeScriptServiceHost} from './typescript_host'; @@ -23,7 +24,7 @@ export function getExternalFiles(project: tss.server.Project): string[]|undefine } } -function completionToEntry(c: Completion): ts.CompletionEntry { +function completionToEntry(c: Completion): tss.CompletionEntry { return { // TODO: remove any and fix type error. kind: c.kind as any, @@ -44,15 +45,15 @@ function diagnosticChainToDiagnosticChain(chain: DiagnosticMessageChain): } function diagnosticMessageToDiagnosticMessageText(message: string | DiagnosticMessageChain): string| - ts.DiagnosticMessageChain { + tss.DiagnosticMessageChain { if (typeof message === 'string') { return message; } return diagnosticChainToDiagnosticChain(message); } -function diagnosticToDiagnostic(d: Diagnostic, file: ts.SourceFile): ts.Diagnostic { - const result = { +function diagnosticToDiagnostic(d: Diagnostic, file: tss.SourceFile | undefined): tss.Diagnostic { + return { file, start: d.span.start, length: d.span.end - d.span.start, @@ -61,154 +62,131 @@ function diagnosticToDiagnostic(d: Diagnostic, file: ts.SourceFile): ts.Diagnost code: 0, source: 'ng' }; - return result; } -export function create(info: tss.server.PluginCreateInfo): ts.LanguageService { - const oldLS: ts.LanguageService = info.languageService; - const proxy: ts.LanguageService = Object.assign({}, oldLS); - const logger = info.project.projectService.logger; - - function tryOperation(attempting: string, callback: () => T): T|null { - try { - return callback(); - } catch (e) { - logger.info(`Failed to ${attempting}: ${e.toString()}`); - logger.info(`Stack trace: ${e.stack}`); - return null; - } - } - - const serviceHost = new TypeScriptServiceHost(info.languageServiceHost, oldLS); - const ls = createLanguageService(serviceHost); - projectHostMap.set(info.project, serviceHost); +export function create(info: tss.server.PluginCreateInfo): tss.LanguageService { + const {project, languageService: tsLS, languageServiceHost: tsLSHost, config} = info; + // This plugin could operate under two different modes: + // 1. TS + Angular + // Plugin augments TS language service to provide additional Angular + // information. This only works with inline templates and is meant to be + // used as a local plugin (configured via tsconfig.json) + // 2. Angular only + // Plugin only provides information on Angular templates, no TS info at all. + // This effectively disables native TS features and is meant for internal + // use only. + const angularOnly = config ? config.angularOnly === true : false; + const proxy: tss.LanguageService = Object.assign({}, tsLS); + const ngLSHost = new TypeScriptServiceHost(tsLSHost, tsLS); + const ngLS = createLanguageService(ngLSHost); + projectHostMap.set(project, ngLSHost); proxy.getCompletionsAtPosition = function( - fileName: string, position: number, options: ts.GetCompletionsAtPositionOptions|undefined) { - let base = oldLS.getCompletionsAtPosition(fileName, position, options) || { + fileName: string, position: number, options: tss.GetCompletionsAtPositionOptions|undefined) { + if (!angularOnly) { + const results = tsLS.getCompletionsAtPosition(fileName, position, options); + if (results && results.entries.length) { + // If TS could answer the query, then return results immediately. + return results; + } + } + const results = ngLS.getCompletionsAt(fileName, position); + if (!results || !results.length) { + return; + } + return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, - entries: [] + entries: results.map(completionToEntry), }; - tryOperation('get completions', () => { - const results = ls.getCompletionsAt(fileName, position); - if (results && results.length) { - if (base === undefined) { - base = { - isGlobalCompletion: false, - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: [] - }; - } - for (const entry of results) { - base.entries.push(completionToEntry(entry)); - } - } - }); - return base; }; - proxy.getQuickInfoAtPosition = function(fileName: string, position: number): ts.QuickInfo | + proxy.getQuickInfoAtPosition = function(fileName: string, position: number): tss.QuickInfo | undefined { - const base = oldLS.getQuickInfoAtPosition(fileName, position); - const ours = ls.getHoverAt(fileName, position); - if (!ours) { - return base; + if (!angularOnly) { + const result = tsLS.getQuickInfoAtPosition(fileName, position); + if (result) { + // If TS could answer the query, then return results immediately. + return result; + } } - const result: ts.QuickInfo = { + const result = ngLS.getHoverAt(fileName, position); + if (!result) { + return; + } + return { + // TODO(kyliau): Provide more useful info for kind and kindModifiers kind: ts.ScriptElementKind.unknown, kindModifiers: ts.ScriptElementKindModifier.none, textSpan: { - start: ours.span.start, - length: ours.span.end - ours.span.start, + start: result.span.start, + length: result.span.end - result.span.start, }, - displayParts: ours.text.map(part => { + displayParts: result.text.map((part) => { return { text: part.text, kind: part.language || 'angular', }; }), - documentation: [], }; - if (base && base.tags) { - result.tags = base.tags; - } - return result; }; - proxy.getSemanticDiagnostics = function(fileName: string) { - let result = oldLS.getSemanticDiagnostics(fileName); - const base = result || []; - tryOperation('get diagnostics', () => { - logger.info(`Computing Angular semantic diagnostics...`); - const ours = ls.getDiagnostics(fileName); - if (ours && ours.length) { - const file = oldLS.getProgram() !.getSourceFile(fileName); - if (file) { - base.push.apply(base, ours.map(d => diagnosticToDiagnostic(d, file))); - } - } - }); - - return base; + proxy.getSemanticDiagnostics = function(fileName: string): tss.Diagnostic[] { + const results: tss.Diagnostic[] = []; + if (!angularOnly) { + const tsResults = tsLS.getSemanticDiagnostics(fileName); + results.push(...tsResults); + } + // For semantic diagnostics we need to combine both TS + Angular results + const ngResults = ngLS.getDiagnostics(fileName); + if (!ngResults.length) { + return results; + } + const sourceFile = fileName.endsWith('.ts') ? ngLSHost.getSourceFile(fileName) : undefined; + results.push(...ngResults.map(d => diagnosticToDiagnostic(d, sourceFile))); + return results; }; proxy.getDefinitionAtPosition = function(fileName: string, position: number): - ReadonlyArray| + ReadonlyArray| undefined { - const base = oldLS.getDefinitionAtPosition(fileName, position); - if (base && base.length) { - return base; + if (!angularOnly) { + const results = tsLS.getDefinitionAtPosition(fileName, position); + if (results) { + // If TS could answer the query, then return results immediately. + return results; + } } - const ours = ls.getDefinitionAt(fileName, position); - if (ours && ours.length) { - return ours.map((loc: Location) => { - return { - fileName: loc.fileName, - textSpan: { - start: loc.span.start, - length: loc.span.end - loc.span.start, - }, - name: '', - kind: ts.ScriptElementKind.unknown, - containerName: loc.fileName, - containerKind: ts.ScriptElementKind.unknown, - }; - }); + const results = ngLS.getDefinitionAt(fileName, position); + if (!results) { + return; } + return results.map(ngLocationToTsDefinitionInfo); }; proxy.getDefinitionAndBoundSpan = function(fileName: string, position: number): - ts.DefinitionInfoAndBoundSpan | + tss.DefinitionInfoAndBoundSpan | undefined { - const base = oldLS.getDefinitionAndBoundSpan(fileName, position); - if (base && base.definitions && base.definitions.length) { - return base; + if (!angularOnly) { + const result = tsLS.getDefinitionAndBoundSpan(fileName, position); + if (result) { + // If TS could answer the query, then return results immediately. + return result; + } } - const ours = ls.getDefinitionAt(fileName, position); - if (ours && ours.length) { - return { - definitions: ours.map((loc: Location) => { - return { - fileName: loc.fileName, - textSpan: { - start: loc.span.start, - length: loc.span.end - loc.span.start, - }, - name: '', - kind: ts.ScriptElementKind.unknown, - containerName: loc.fileName, - containerKind: ts.ScriptElementKind.unknown, - }; - }), - textSpan: { - start: ours[0].span.start, - length: ours[0].span.end - ours[0].span.start, - }, - }; + const results = ngLS.getDefinitionAt(fileName, position); + if (!results || !results.length) { + return; } + const {span} = results[0]; + return { + definitions: results.map(ngLocationToTsDefinitionInfo), + textSpan: { + start: span.start, + length: span.end - span.start, + }, + }; }; return proxy; diff --git a/packages/language-service/src/types.ts b/packages/language-service/src/types.ts index 39ef7ab12a..b6c25e245b 100644 --- a/packages/language-service/src/types.ts +++ b/packages/language-service/src/types.ts @@ -243,9 +243,9 @@ export interface Completion { /** * A sequence of completions. * - * @publicApi + * @deprecated */ -export type Completions = Completion[] | undefined; +export type Completions = Completion[]; /** * A file and span. @@ -312,7 +312,7 @@ export interface Diagnostic { /** * A sequence of diagnostic message. * - * @publicApi + * @deprecated */ export type Diagnostics = Diagnostic[]; @@ -384,17 +384,17 @@ export interface LanguageService { /** * Returns a list of all error for all templates in the given file. */ - getDiagnostics(fileName: string): Diagnostics|undefined; + getDiagnostics(fileName: string): Diagnostic[]; /** * Return the completions at the given position. */ - getCompletionsAt(fileName: string, position: number): Completions|undefined; + getCompletionsAt(fileName: string, position: number): Completion[]|undefined; /** * Return the definition location for the symbol at position. */ - getDefinitionAt(fileName: string, position: number): Definition|undefined; + getDefinitionAt(fileName: string, position: number): Location[]|undefined; /** * Return the hover information for the symbol at position. diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 23919304fc..518c7acae6 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -10,7 +10,7 @@ import 'reflect-metadata'; import * as ts from 'typescript'; import {createLanguageService} from '../src/language_service'; -import {Completions} from '../src/types'; +import {Completion} from '../src/types'; import {TypeScriptServiceHost} from '../src/typescript_host'; import {toh} from './test_data'; @@ -228,7 +228,8 @@ export class MyComponent { }); -function expectEntries(locationMarker: string, completions: Completions, ...names: string[]) { +function expectEntries( + locationMarker: string, completions: Completion[] | undefined, ...names: string[]) { let entries: {[name: string]: boolean} = {}; if (!completions) { throw new Error( diff --git a/packages/language-service/test/ts_plugin_spec.ts b/packages/language-service/test/ts_plugin_spec.ts index 5ccbbcd67c..dcd50ca6c0 100644 --- a/packages/language-service/test/ts_plugin_spec.ts +++ b/packages/language-service/test/ts_plugin_spec.ts @@ -7,21 +7,16 @@ */ import 'reflect-metadata'; - import * as ts from 'typescript'; - import {create} from '../src/ts_plugin'; - import {toh} from './test_data'; import {MockTypescriptHost} from './test_utils'; describe('plugin', () => { - let documentRegistry = ts.createDocumentRegistry(); - let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); - let service = ts.createLanguageService(mockHost, documentRegistry); - let program = service.getProgram(); - - const mockProject = {projectService: {logger: {info: function() {}}}}; + const mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + const service = ts.createLanguageService(mockHost); + const program = service.getProgram(); + const plugin = createPlugin(service, mockHost); it('should not report errors on tour of heroes', () => { expectNoDiagnostics(service.getCompilerOptionsDiagnostics()); @@ -31,15 +26,6 @@ describe('plugin', () => { } }); - - let plugin = create({ - languageService: service, - project: mockProject as any, - languageServiceHost: mockHost, - serverHost: {} as any, - config: {}, - }); - it('should not report template errors on tour of heroes', () => { for (let source of program !.getSourceFiles()) { // Ignore all 'cases.ts' files as they intentionally contain errors. @@ -197,8 +183,54 @@ describe('plugin', () => { 'implicit', 'The template context does not defined a member called \'unknown\''); }); }); + + describe(`with config 'angularOnly = true`, () => { + const ngLS = createPlugin(service, mockHost, {angularOnly: true}); + it('should not report template errors on TOH', () => { + const sourceFiles = ngLS.getProgram() !.getSourceFiles(); + expect(sourceFiles.length).toBeGreaterThan(0); + for (const {fileName} of sourceFiles) { + // Ignore all 'cases.ts' files as they intentionally contain errors. + if (!fileName.endsWith('cases.ts')) { + expectNoDiagnostics(ngLS.getSemanticDiagnostics(fileName)); + } + } + }); + + it('should be able to get entity completions', () => { + const fileName = 'app/app.component.ts'; + const marker = 'entity-amp'; + const position = getMarkerLocation(fileName, marker); + const results = ngLS.getCompletionsAtPosition(fileName, position, {} /* options */); + expect(results).toBeTruthy(); + expectEntries(marker, results !, ...['&', '>', '<', 'ι']); + }); + + it('should report template diagnostics', () => { + // TODO(kyliau): Rename these to end with '-error.ts' + const fileName = 'app/expression-cases.ts'; + const diagnostics = ngLS.getSemanticDiagnostics(fileName); + expect(diagnostics.map(d => d.messageText)).toEqual([ + `Identifier 'foo' is not defined. The component declaration, template variable declarations, and element references do not contain such a member`, + `Identifier 'nam' is not defined. 'Person' does not contain such a member`, + `Identifier 'myField' refers to a private member of the component`, + `Expected a numeric type`, + ]); + }); + }); }); + function createPlugin(tsLS: ts.LanguageService, tsLSHost: ts.LanguageServiceHost, config = {}) { + const project = {projectService: {logger: {info() {}}}}; + return create({ + languageService: tsLS, + languageServiceHost: tsLSHost, + project: project as any, + serverHost: {} as any, + config: {...config}, + }); + } + function getMarkerLocation(fileName: string, locationMarker: string): number { const location = mockHost.getMarkerLocations(fileName) ![locationMarker]; if (location == null) {