Skip to content

Commit

Permalink
feat(language-service): support TS2.2 plugin model
Browse files Browse the repository at this point in the history
  • Loading branch information
RyanCavanaugh authored and matsko committed Jan 9, 2017
1 parent e5c6bb4 commit 99aa49a
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 81 deletions.
2 changes: 1 addition & 1 deletion modules/@angular/compiler/src/aot/static_reflector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 1 addition & 4 deletions modules/@angular/language-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 6 additions & 4 deletions modules/@angular/language-service/src/language_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
164 changes: 111 additions & 53 deletions modules/@angular/language-service/src/ts_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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) {
(<any>proxy)[k] = function() { return (oldLS as any)[k].apply(oldLS, arguments); };
}

/** 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;
function completionToEntry(c: Completion): ts.CompletionEntry {
return {kind: c.kind, name: c.name, sortText: c.sort, kindModifiers: ''};
}

static 'extension-kind' = 'language-service';
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
};
}

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);
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}`);
}
}

/**
* 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
});
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 base;
};

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 = [];
}
return previous;
}
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;
};

/**
* 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<ts.CompletionEntry>(
entry =>
({name: entry.name, kind: entry.kind, kindModifiers: '', sortText: entry.sort}))
};
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;
}
9 changes: 5 additions & 4 deletions modules/@angular/language-service/test/test_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 21 additions & 15 deletions modules/@angular/language-service/test/ts_plugin_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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()) {
Expand All @@ -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));
}
}
});
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -206,31 +208,32 @@ 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);
expect(error.length).toEqual(end - start);
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 ')}`);
}
});


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;
Expand All @@ -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(', ')}`);
}
}
}
Expand Down

1 comment on commit 99aa49a

@JamesHenry
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exciting! Great to see direct TS contributions to Angular. Awesome work, @RyanCavanaugh!

Please sign in to comment.