feat(language-service): completions for output $event properties in (#34570)

This commit adds support for completions of properties on `$event`
variables in bound outputs.

This is the second major PR to support completions for `$event`
variables (https://github.com/angular/vscode-ng-language-service/issues/531).
The final completion support that must be provided is for `$event`
variables in bindings targeting DOM events, like `(click)`.

PR Close #34570
This commit is contained in:
ayazhafiz 2019-12-26 13:26:54 -06:00 committed by Andrew Kushnir
parent 24864ee71e
commit e7c74cbd69
6 changed files with 64 additions and 24 deletions

View File

@ -316,7 +316,6 @@ function attributeValueCompletions(info: AstResult, htmlPath: HtmlAstPath): ng.C
templatePath.tail.visit(visitor, null); templatePath.tail.visit(visitor, null);
return visitor.results; return visitor.results;
} }
// In order to provide accurate attribute value completion, we need to know // In order to provide accurate attribute value completion, we need to know
// what the LHS is, and construct the proper AST if it is missing. // what the LHS is, and construct the proper AST if it is missing.
const htmlAttr = htmlPath.tail as Attribute; const htmlAttr = htmlPath.tail as Attribute;

View File

@ -12,7 +12,7 @@ import * as ts from 'typescript';
import {AstType, ExpressionDiagnosticsContext, TypeDiagnostic} from './expression_type'; import {AstType, ExpressionDiagnosticsContext, TypeDiagnostic} from './expression_type';
import {BuiltinType, Definition, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './symbols'; import {BuiltinType, Definition, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './symbols';
import {Diagnostic} from './types'; import {Diagnostic} from './types';
import {getPathToNodeAtPosition} from './utils'; import {findOutputBinding, getPathToNodeAtPosition} from './utils';
export interface DiagnosticTemplateInfo { export interface DiagnosticTemplateInfo {
fileName?: string; fileName?: string;
@ -193,26 +193,51 @@ function refinedVariableType(
return query.getBuiltinType(BuiltinType.Any); return query.getBuiltinType(BuiltinType.Any);
} }
function getEventDeclaration(info: DiagnosticTemplateInfo, path: TemplateAstPath) { function getEventDeclaration(
let result: SymbolDeclaration[] = []; info: DiagnosticTemplateInfo, path: TemplateAstPath): SymbolDeclaration|undefined {
if (path.tail instanceof BoundEventAst) { const event = path.tail;
// TODO: Determine the type of the event parameter based on the Observable<T> or EventEmitter<T> if (!(event instanceof BoundEventAst)) {
// of the event. // No event available in this context.
result = [{name: '$event', kind: 'variable', type: info.query.getBuiltinType(BuiltinType.Any)}]; 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( export function getExpressionScope(
info: DiagnosticTemplateInfo, path: TemplateAstPath): SymbolTable { info: DiagnosticTemplateInfo, path: TemplateAstPath): SymbolTable {
let result = info.members; let result = info.members;
const references = getReferences(info); const references = getReferences(info);
const variables = getVarDeclarations(info, path); const variables = getVarDeclarations(info, path);
const events = getEventDeclaration(info, path); const event = getEventDeclaration(info, path);
if (references.length || variables.length || events.length) { if (references.length || variables.length || event) {
const referenceTable = info.query.createSymbolTable(references); const referenceTable = info.query.createSymbolTable(references);
const variableTable = info.query.createSymbolTable(variables); 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]); result = info.query.mergeSymbolTable([result, referenceTable, variableTable, eventsTable]);
} }
return result; return result;

View File

@ -109,7 +109,7 @@ function locateSymbol(ast: TemplateAst, path: TemplateAstPath, info: AstResult):
visitVariable(ast) {}, visitVariable(ast) {},
visitEvent(ast) { visitEvent(ast) {
if (!attributeValueSymbol()) { if (!attributeValueSymbol()) {
symbol = findOutputBinding(info, path, ast); symbol = findOutputBinding(ast, path, info.template.query);
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.EVENT); symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.EVENT);
span = spanOf(ast); span = spanOf(ast);
} }

View File

@ -11,7 +11,7 @@ import * as ts from 'typescript';
import {AstResult, SelectorInfo} from './common'; import {AstResult, SelectorInfo} from './common';
import {DiagnosticTemplateInfo} from './expression_diagnostics'; import {DiagnosticTemplateInfo} from './expression_diagnostics';
import {Span, Symbol} from './types'; import {Span, Symbol, SymbolQuery} from './types';
export interface SpanHolder { export interface SpanHolder {
sourceSpan: ParseSourceSpan; sourceSpan: ParseSourceSpan;
@ -268,14 +268,14 @@ export function invertMap(obj: {[name: string]: string}): {[name: string]: strin
* @param path narrowing * @param path narrowing
*/ */
export function findOutputBinding( export function findOutputBinding(
info: AstResult, path: TemplateAstPath, binding: BoundEventAst): Symbol|undefined { binding: BoundEventAst, path: TemplateAstPath, query: SymbolQuery): Symbol|undefined {
const element = path.first(ElementAst); const element = path.first(ElementAst);
if (element) { if (element) {
for (const directive of element.directives) { for (const directive of element.directives) {
const invertedOutputs = invertMap(directive.directive.outputs); const invertedOutputs = invertMap(directive.directive.outputs);
const fieldName = invertedOutputs[binding.name]; const fieldName = invertedOutputs[binding.name];
if (fieldName) { if (fieldName) {
const classSymbol = info.template.query.getTypeSymbol(directive.directive.type.reference); const classSymbol = query.getTypeSymbol(directive.directive.type.reference);
if (classSymbol) { if (classSymbol) {
return classSymbol.members().get(fieldName); return classSymbol.members().get(fieldName);
} }

View File

@ -758,10 +758,17 @@ describe('completions', () => {
it('should suggest $event in event bindings', () => { it('should suggest $event in event bindings', () => {
mockHost.override(TEST_TEMPLATE, `<div (click)="myClick(~{cursor});"></div>`); mockHost.override(TEST_TEMPLATE, `<div (click)="myClick(~{cursor});"></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
debugger; const completions = ngLS.getCompletionsAtPosition(TEST_TEMPLATE, marker.start);
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.VARIABLE, ['$event']); 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']);
});
}); });
}); });

View File

@ -311,16 +311,14 @@ describe('diagnostics', () => {
describe('with $event', () => { describe('with $event', () => {
it('should accept an event', () => { it('should accept an event', () => {
const fileName = '/app/test.ng'; mockHost.override(TEST_TEMPLATE, '<div (click)="myClick($event)">Click me!</div>');
mockHost.override(fileName, '<div (click)="myClick($event)">Click me!</div>'); const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
const diagnostics = ngLS.getSemanticDiagnostics(fileName);
expect(diagnostics).toEqual([]); expect(diagnostics).toEqual([]);
}); });
it('should reject it when not in an event binding', () => { it('should reject it when not in an event binding', () => {
const fileName = '/app/test.ng'; const content = mockHost.override(TEST_TEMPLATE, '<div [tabIndex]="$event"></div>');
const content = mockHost.override(fileName, '<div [tabIndex]="$event"></div>'); const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE) !;
const diagnostics = ngLS.getSemanticDiagnostics(fileName) !;
expect(diagnostics.length).toBe(1); expect(diagnostics.length).toBe(1);
const {messageText, start, length} = diagnostics[0]; const {messageText, start, length} = diagnostics[0];
expect(messageText) expect(messageText)
@ -330,6 +328,17 @@ describe('diagnostics', () => {
expect(start).toBe(content.lastIndexOf(keyword)); expect(start).toBe(content.lastIndexOf(keyword));
expect(length).toBe(keyword.length); 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', () => { it('should not crash with a incomplete *ngFor', () => {