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:
parent
24864ee71e
commit
e7c74cbd69
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
Loading…
Reference in New Issue