From 99aa49ab6c2b430fc7be8091a255f32117eff322 Mon Sep 17 00:00:00 2001 From: Ryan Cavanaugh Date: Fri, 6 Jan 2017 20:43:17 -0800 Subject: [PATCH] feat(language-service): support TS2.2 plugin model --- .../compiler/src/aot/static_reflector.ts | 2 +- modules/@angular/language-service/index.ts | 5 +- .../language-service/src/language_service.ts | 10 +- .../language-service/src/ts_plugin.ts | 172 ++++++++++++------ .../language-service/test/test_utils.ts | 9 +- .../language-service/test/ts_plugin_spec.ts | 36 ++-- 6 files changed, 149 insertions(+), 85 deletions(-) diff --git a/modules/@angular/compiler/src/aot/static_reflector.ts b/modules/@angular/compiler/src/aot/static_reflector.ts index 27a3c25c1b..03f6fc36dc 100644 --- a/modules/@angular/compiler/src/aot/static_reflector.ts +++ b/modules/@angular/compiler/src/aot/static_reflector.ts @@ -84,7 +84,7 @@ export class StaticReflector implements ReflectorReader { const classMetadata = this.getTypeMetadata(type); if (classMetadata['extends']) { const parentType = this.simplify(type, classMetadata['extends']); - if (parentType instanceof StaticSymbol) { + if (parentType && (parentType instanceof StaticSymbol)) { const parentAnnotations = this.annotations(parentType); annotations.push(...parentAnnotations); } diff --git a/modules/@angular/language-service/index.ts b/modules/@angular/language-service/index.ts index bf641571ad..53c13320f8 100644 --- a/modules/@angular/language-service/index.ts +++ b/modules/@angular/language-service/index.ts @@ -11,11 +11,8 @@ * @description * Entry point for all public APIs of the language service package. */ -import {LanguageServicePlugin} from './src/ts_plugin'; - export {createLanguageService} from './src/language_service'; +export {create} from './src/ts_plugin'; export {Completion, Completions, Declaration, Declarations, Definition, Diagnostic, Diagnostics, Hover, HoverTextSection, LanguageService, LanguageServiceHost, Location, Span, TemplateSource, TemplateSources} from './src/types'; export {TypeScriptServiceHost, createLanguageServiceFromTypescript} from './src/typescript_host'; export {VERSION} from './src/version'; - -export default LanguageServicePlugin; diff --git a/modules/@angular/language-service/src/language_service.ts b/modules/@angular/language-service/src/language_service.ts index a221297bb7..226853ae03 100644 --- a/modules/@angular/language-service/src/language_service.ts +++ b/modules/@angular/language-service/src/language_service.ts @@ -107,8 +107,9 @@ class LanguageServiceImpl implements LanguageService { getTemplateAst(template: TemplateSource, contextFile: string): AstResult { let result: AstResult; try { - const {metadata} = + const resolvedMetadata = this.metadataResolver.getNonNormalizedDirectiveMetadata(template.type as any); + const metadata = resolvedMetadata && resolvedMetadata.metadata; if (metadata) { const rawHtmlParser = new HtmlParser(); const htmlParser = new I18NHtmlParser(rawHtmlParser); @@ -124,9 +125,10 @@ class LanguageServiceImpl implements LanguageService { ngModule = findSuitableDefaultModule(analyzedModules); } if (ngModule) { - const directives = ngModule.transitiveModule.directives.map( - d => this.host.resolver.getNonNormalizedDirectiveMetadata(d.reference) - .metadata.toSummary()); + const resolvedDirectives = ngModule.transitiveModule.directives.map( + d => this.host.resolver.getNonNormalizedDirectiveMetadata(d.reference)); + const directives = + resolvedDirectives.filter(d => d !== null).map(d => d.metadata.toSummary()); const pipes = ngModule.transitiveModule.pipes.map( p => this.host.resolver.getOrLoadPipeMetadata(p.reference).toSummary()); const schemas = ngModule.schemas; diff --git a/modules/@angular/language-service/src/ts_plugin.ts b/modules/@angular/language-service/src/ts_plugin.ts index aca66c88dd..f24d5bd279 100644 --- a/modules/@angular/language-service/src/ts_plugin.ts +++ b/modules/@angular/language-service/src/ts_plugin.ts @@ -9,67 +9,125 @@ import * as ts from 'typescript'; import {createLanguageService} from './language_service'; -import {LanguageService, LanguageServiceHost} from './types'; +import {Completion, Diagnostic, LanguageService, LanguageServiceHost} from './types'; import {TypeScriptServiceHost} from './typescript_host'; - -/** A plugin to TypeScript's langauge service that provide language services for - * templates in string literals. - * - * @experimental - */ -export class LanguageServicePlugin { - private serviceHost: TypeScriptServiceHost; - private service: LanguageService; - private host: ts.LanguageServiceHost; - - static 'extension-kind' = 'language-service'; - - constructor(config: { - host: ts.LanguageServiceHost; service: ts.LanguageService; - registry?: ts.DocumentRegistry, args?: any - }) { - this.host = config.host; - this.serviceHost = new TypeScriptServiceHost(config.host, config.service); - this.service = createLanguageService(this.serviceHost); - this.serviceHost.setSite(this.service); +export function create(info: any /* ts.server.PluginCreateInfo */): ts.LanguageService { + // Create the proxy + const proxy: ts.LanguageService = Object.create(null); + const oldLS: ts.LanguageService = info.languageService; + for (const k in oldLS) { + (proxy)[k] = function() { return (oldLS as any)[k].apply(oldLS, arguments); }; } - /** - * Augment the diagnostics reported by TypeScript with errors from the templates in string - * literals. - */ - getSemanticDiagnosticsFilter(fileName: string, previous: ts.Diagnostic[]): ts.Diagnostic[] { - let errors = this.service.getDiagnostics(fileName); - if (errors && errors.length) { - let file = this.serviceHost.getSourceFile(fileName); - for (const error of errors) { - previous.push({ - file, - start: error.span.start, - length: error.span.end - error.span.start, - messageText: error.message, - category: ts.DiagnosticCategory.Error, - code: 0 - }); + function completionToEntry(c: Completion): ts.CompletionEntry { + return {kind: c.kind, name: c.name, sortText: c.sort, kindModifiers: ''}; + } + + function diagnosticToDiagnostic(d: Diagnostic, file: ts.SourceFile): ts.Diagnostic { + return { + file, + start: d.span.start, + length: d.span.end - d.span.start, + messageText: d.message, + category: ts.DiagnosticCategory.Error, + code: 0 + }; + } + + function tryOperation(attempting: string, callback: () => void) { + try { + callback(); + } catch (e) { + info.project.projectService.logger.info(`Failed to ${attempting}: ${e.toString()}`); + info.project.projectService.logger.info(`Stack trace: ${e.stack}`); + } + } + + const serviceHost = new TypeScriptServiceHost(info.languageServiceHost, info.languageService); + const ls = createLanguageService(serviceHost); + serviceHost.setSite(ls); + + proxy.getCompletionsAtPosition = function(fileName: string, position: number) { + let base = oldLS.getCompletionsAtPosition(fileName, position); + tryOperation('get completions', () => { + const results = ls.getCompletionsAt(fileName, position); + if (results && results.length) { + if (base === undefined) { + base = {isMemberCompletion: false, isNewIdentifierLocation: false, entries: []}; + } + for (const entry of results) { + base.entries.push(completionToEntry(entry)); + } } - } - return previous; - } + }); + return base; + }; - /** - * Get completions for angular templates if one is at the given position. - */ - getCompletionsAtPosition(fileName: string, position: number): ts.CompletionInfo { - let result = this.service.getCompletionsAt(fileName, position); - if (result) { - return { - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: result.map( - entry => - ({name: entry.name, kind: entry.kind, kindModifiers: '', sortText: entry.sort})) - }; + proxy.getQuickInfoAtPosition = function(fileName: string, position: number): ts.QuickInfo { + let base = oldLS.getQuickInfoAtPosition(fileName, position); + tryOperation('get quick info', () => { + const ours = ls.getHoverAt(fileName, position); + if (ours) { + const displayParts: typeof base.displayParts = []; + for (const part of ours.text) { + displayParts.push({kind: part.language, text: part.text}); + } + base = { + displayParts, + documentation: [], + kind: 'angular', + kindModifiers: 'what does this do?', + textSpan: {start: ours.span.start, length: ours.span.end - ours.span.start} + }; + } + }); + + return base; + }; + + proxy.getSemanticDiagnostics = function(fileName: string) { + let base = oldLS.getSemanticDiagnostics(fileName); + if (base === undefined) { + base = []; } - } -} \ No newline at end of file + tryOperation('get diagnostics', () => { + info.project.projectService.logger.info(`Computing Angular semantic diagnostics...`); + const ours = ls.getDiagnostics(fileName); + if (ours && ours.length) { + const file = oldLS.getProgram().getSourceFile(fileName); + base.push.apply(base, ours.map(d => diagnosticToDiagnostic(d, file))); + } + }); + + return base; + }; + + proxy.getDefinitionAtPosition = function( + fileName: string, position: number): ts.DefinitionInfo[] { + let base = oldLS.getDefinitionAtPosition(fileName, position); + if (base && base.length) { + return base; + } + + tryOperation('get definition', () => { + const ours = ls.getDefinitionAt(fileName, position); + if (ours && ours.length) { + base = base || []; + for (const loc of ours) { + base.push({ + fileName: loc.fileName, + textSpan: {start: loc.span.start, length: loc.span.end - loc.span.start}, + name: '', + kind: 'definition', + containerName: loc.fileName, + containerKind: 'file' + }); + } + } + }); + return base; + }; + + return proxy; +} diff --git a/modules/@angular/language-service/test/test_utils.ts b/modules/@angular/language-service/test/test_utils.ts index c4d722dac7..df4b658f7d 100644 --- a/modules/@angular/language-service/test/test_utils.ts +++ b/modules/@angular/language-service/test/test_utils.ts @@ -71,12 +71,13 @@ export class MockTypescriptHost implements ts.LanguageServiceHost { private projectVersion = 0; constructor(private scriptNames: string[], private data: MockData) { - let angularIndex = module.filename.indexOf('@angular'); + const moduleFilename = module.filename.replace(/\\/g, '/'); + let angularIndex = moduleFilename.indexOf('@angular'); if (angularIndex >= 0) - this.angularPath = module.filename.substr(0, angularIndex).replace('/all/', '/all/@angular/'); - let distIndex = module.filename.indexOf('/dist/all'); + this.angularPath = moduleFilename.substr(0, angularIndex).replace('/all/', '/all/@angular/'); + let distIndex = moduleFilename.indexOf('/dist/all'); if (distIndex >= 0) - this.nodeModulesPath = path.join(module.filename.substr(0, distIndex), 'node_modules'); + this.nodeModulesPath = path.join(moduleFilename.substr(0, distIndex), 'node_modules'); } override(fileName: string, content: string) { diff --git a/modules/@angular/language-service/test/ts_plugin_spec.ts b/modules/@angular/language-service/test/ts_plugin_spec.ts index 5c15f9c616..f9ab921f86 100644 --- a/modules/@angular/language-service/test/ts_plugin_spec.ts +++ b/modules/@angular/language-service/test/ts_plugin_spec.ts @@ -10,7 +10,7 @@ import 'reflect-metadata'; import * as ts from 'typescript'; -import {LanguageServicePlugin} from '../src/ts_plugin'; +import {create} from '../src/ts_plugin'; import {toh} from './test_data'; import {MockTypescriptHost} from './test_utils'; @@ -21,6 +21,8 @@ describe('plugin', () => { let service = ts.createLanguageService(mockHost, documentRegistry); let program = service.getProgram(); + const mockProject = {projectService: {logger: {info: function() {}}}}; + it('should not report errors on tour of heroes', () => { expectNoDiagnostics(service.getCompilerOptionsDiagnostics()); for (let source of program.getSourceFiles()) { @@ -29,13 +31,15 @@ describe('plugin', () => { } }); - let plugin = new LanguageServicePlugin({host: mockHost, service, registry: documentRegistry}); + + let plugin = create( + {ts: ts, languageService: service, project: mockProject, languageServiceHost: mockHost}); 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. if (!source.fileName.endsWith('cases.ts')) { - expectNoDiagnostics(plugin.getSemanticDiagnosticsFilter(source.fileName, [])); + expectNoDiagnostics(plugin.getSemanticDiagnostics(source.fileName)); } } }); @@ -109,8 +113,6 @@ describe('plugin', () => { describe('with a *ngFor', () => { it('should include a let for empty attribute', () => { contains('app/parsing-cases.ts', 'for-empty', 'let'); }); - it('should not suggest any entries if in the name part of a let', - () => { expectEmpty('app/parsing-cases.ts', 'for-let-empty'); }); it('should suggest NgForRow members for let initialization expression', () => { contains( 'app/parsing-cases.ts', 'for-let-i-equal', 'index', 'count', 'first', 'last', 'even', @@ -206,13 +208,13 @@ describe('plugin', () => { function expectEmpty(fileName: string, locationMarker: string) { const location = getMarkerLocation(fileName, locationMarker); - expect(plugin.getCompletionsAtPosition(fileName, location).entries).toEqual([]); + expect(plugin.getCompletionsAtPosition(fileName, location).entries || []).toEqual([]); } function expectSemanticError(fileName: string, locationMarker: string, message: string) { const start = getMarkerLocation(fileName, locationMarker); const end = getMarkerLocation(fileName, locationMarker + '-end'); - const errors = plugin.getSemanticDiagnosticsFilter(fileName, []); + const errors = plugin.getSemanticDiagnostics(fileName); for (const error of errors) { if (error.messageText.toString().indexOf(message) >= 0) { expect(error.start).toEqual(start); @@ -220,8 +222,9 @@ describe('plugin', () => { return; } } - throw new Error( - `Expected error messages to contain ${message}, in messages:\n ${errors.map(e => e.messageText.toString()).join(',\n ')}`); + throw new Error(`Expected error messages to contain ${message}, in messages:\n ${errors + .map(e => e.messageText.toString()) + .join(',\n ')}`); } }); @@ -229,8 +232,8 @@ describe('plugin', () => { function expectEntries(locationMarker: string, info: ts.CompletionInfo, ...names: string[]) { let entries: {[name: string]: boolean} = {}; if (!info) { - throw new Error( - `Expected result from ${locationMarker} to include ${names.join(', ')} but no result provided`); + throw new Error(`Expected result from ${locationMarker} to include ${names.join( + ', ')} but no result provided`); } else { for (let entry of info.entries) { entries[entry.name] = true; @@ -240,12 +243,15 @@ function expectEntries(locationMarker: string, info: ts.CompletionInfo, ...names let missing = shouldContains.filter(name => !entries[name]); let present = shouldNotContain.map(name => name.substr(1)).filter(name => entries[name]); if (missing.length) { - throw new Error( - `Expected result from ${locationMarker} to include at least one of the following, ${missing.join(', ')}, in the list of entries ${info.entries.map(entry => entry.name).join(', ')}`); + throw new Error(`Expected result from ${locationMarker + } to include at least one of the following, ${missing + .join(', ')}, in the list of entries ${info.entries.map(entry => entry.name) + .join(', ')}`); } if (present.length) { - throw new Error( - `Unexpected member${present.length > 1 ? 's': ''} included in result: ${present.join(', ')}`); + throw new Error(`Unexpected member${present.length > 1 ? 's' : + '' + } included in result: ${present.join(', ')}`); } } }