Skip to content
47 changes: 36 additions & 11 deletions packages/language-service/src/expression_diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as ts from 'typescript';
import {AstType, ExpressionDiagnosticsContext, TypeDiagnostic} from './expression_type';
import {BuiltinType, Definition, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './symbols';
import {Diagnostic} from './types';
import {getPathToNodeAtPosition} from './utils';
import {findOutputBinding, getPathToNodeAtPosition} from './utils';

export interface DiagnosticTemplateInfo {
fileName?: string;
Expand Down Expand Up @@ -193,26 +193,51 @@ function refinedVariableType(
return query.getBuiltinType(BuiltinType.Any);
}

function getEventDeclaration(info: DiagnosticTemplateInfo, path: TemplateAstPath) {
let result: SymbolDeclaration[] = [];
if (path.tail instanceof BoundEventAst) {
// TODO: Determine the type of the event parameter based on the Observable<T> or EventEmitter<T>
// of the event.
result = [{name: '$event', kind: 'variable', type: info.query.getBuiltinType(BuiltinType.Any)}];
function getEventDeclaration(
info: DiagnosticTemplateInfo, path: TemplateAstPath): SymbolDeclaration|undefined {
const event = path.tail;
if (!(event instanceof BoundEventAst)) {
Comment thread
ayazhafiz marked this conversation as resolved.
Resolved
// No event available in this context.
return;
}
return result;

const genericEvent: SymbolDeclaration = {
name: '$event',
kind: 'variable',
type: info.query.getBuiltinType(BuiltinType.Any),
};

const outputSymbol = findOutputBinding(event, path, info.query);
if (!outputSymbol) {
// The `$event` variable doesn't belong to an output, so its type can't be refined.
// TODO: type `$event` variables in bindings to DOM events.
return genericEvent;
}

// The raw event type is wrapped in a generic, like EventEmitter<T> or Observable<T>.
const ta = outputSymbol.typeArguments();
if (!ta || ta.length !== 1) return genericEvent;
const eventType = ta[0];

return {...genericEvent, type: eventType};
}

/**
* Returns the symbols available in a particular scope of a template.
* @param info parsed template information
* @param path path of template nodes narrowing to the context the expression scope should be
* derived for.
*/
export function getExpressionScope(
info: DiagnosticTemplateInfo, path: TemplateAstPath): SymbolTable {
let result = info.members;
const references = getReferences(info);
const variables = getVarDeclarations(info, path);
const events = getEventDeclaration(info, path);
if (references.length || variables.length || events.length) {
const event = getEventDeclaration(info, path);
if (references.length || variables.length || event) {
const referenceTable = info.query.createSymbolTable(references);
const variableTable = info.query.createSymbolTable(variables);
const eventsTable = info.query.createSymbolTable(events);
const eventsTable = info.query.createSymbolTable(event ? [event] : []);
result = info.query.mergeSymbolTable([result, referenceTable, variableTable, eventsTable]);
}
return result;
Expand Down
2 changes: 1 addition & 1 deletion packages/language-service/src/locate_symbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function locateSymbol(ast: TemplateAst, path: TemplateAstPath, info: AstResult):
visitVariable(ast) {},
visitEvent(ast) {
if (!attributeValueSymbol()) {
symbol = findOutputBinding(info, path, ast);
symbol = findOutputBinding(ast, path, info.template.query);
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.EVENT);
span = spanOf(ast);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/language-service/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as ts from 'typescript';

import {AstResult, SelectorInfo} from './common';
import {DiagnosticTemplateInfo} from './expression_diagnostics';
import {Span, Symbol} from './types';
import {Span, Symbol, SymbolQuery} from './types';

export interface SpanHolder {
sourceSpan: ParseSourceSpan;
Expand Down Expand Up @@ -268,14 +268,14 @@ export function invertMap(obj: {[name: string]: string}): {[name: string]: strin
* @param path narrowing
*/
export function findOutputBinding(
info: AstResult, path: TemplateAstPath, binding: BoundEventAst): Symbol|undefined {
binding: BoundEventAst, path: TemplateAstPath, query: SymbolQuery): Symbol|undefined {
const element = path.first(ElementAst);
if (element) {
for (const directive of element.directives) {
const invertedOutputs = invertMap(directive.directive.outputs);
const fieldName = invertedOutputs[binding.name];
if (fieldName) {
const classSymbol = info.template.query.getTypeSymbol(directive.directive.type.reference);
const classSymbol = query.getTypeSymbol(directive.directive.type.reference);
if (classSymbol) {
return classSymbol.members().get(fieldName);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/language-service/test/completions_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,23 @@ describe('completions', () => {
expectContain(completions, CompletionKind.VARIABLE, ['$event']);
});
});

describe('$event completions', () => {
it('should suggest $event in event bindings', () => {
mockHost.override(TEST_TEMPLATE, `<div (click)="myClick(~{cursor});"></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAtPosition(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.VARIABLE, ['$event']);
});

it('should suggest $event completions in output bindings', () => {
mockHost.override(TEST_TEMPLATE, `<div string-model (modelChange)="$event.~{cursor}"></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAtPosition(TEST_TEMPLATE, marker.start);
// Expect string properties
expectContain(completions, CompletionKind.METHOD, ['charAt', 'substring']);
});
});
});

function expectContain(
Expand Down
21 changes: 15 additions & 6 deletions packages/language-service/test/diagnostics_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,16 +311,14 @@ describe('diagnostics', () => {

describe('with $event', () => {
it('should accept an event', () => {
const fileName = '/app/test.ng';
mockHost.override(fileName, '<div (click)="myClick($event)">Click me!</div>');
const diagnostics = ngLS.getSemanticDiagnostics(fileName);
mockHost.override(TEST_TEMPLATE, '<div (click)="myClick($event)">Click me!</div>');
const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(diagnostics).toEqual([]);
});

it('should reject it when not in an event binding', () => {
const fileName = '/app/test.ng';
const content = mockHost.override(fileName, '<div [tabIndex]="$event"></div>');
const diagnostics = ngLS.getSemanticDiagnostics(fileName) !;
const content = mockHost.override(TEST_TEMPLATE, '<div [tabIndex]="$event"></div>');
const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE) !;
expect(diagnostics.length).toBe(1);
const {messageText, start, length} = diagnostics[0];
expect(messageText)
Expand All @@ -330,6 +328,17 @@ describe('diagnostics', () => {
expect(start).toBe(content.lastIndexOf(keyword));
expect(length).toBe(keyword.length);
});

it('should reject invalid properties on an event type', () => {
const content = mockHost.override(
TEST_TEMPLATE, '<div string-model (modelChange)="$event.notSubstring()"></div>');
const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE) !;
expect(diagnostics.length).toBe(1);
const {messageText, start, length} = diagnostics[0];
expect(messageText).toBe(`Unknown method 'notSubstring'`);
expect(start).toBe(content.indexOf('$event'));
expect(length).toBe('$event.notSubstring()'.length);
});
});

it('should not crash with a incomplete *ngFor', () => {
Expand Down