Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(language-service): improve non-callable error message #35271

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/language-service/src/completions.ts
Expand Up @@ -492,7 +492,7 @@ class ExpressionVisitor extends NullTemplateVisitor {
visitBoundText(ast: BoundTextAst) {
if (inSpan(this.position, ast.value.sourceSpan)) {
const completions = getExpressionCompletions(
this.getExpressionScope(), ast.value, this.position, this.info.template.query);
this.getExpressionScope(), ast.value, this.position, this.info.template);
if (completions) {
this.addSymbolsToCompletions(completions);
}
Expand All @@ -501,7 +501,7 @@ class ExpressionVisitor extends NullTemplateVisitor {

private processExpressionCompletions(value: AST) {
const symbols = getExpressionCompletions(
this.getExpressionScope(), value, this.position, this.info.template.query);
this.getExpressionScope(), value, this.position, this.info.template);
if (symbols) {
this.addSymbolsToCompletions(symbols);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/language-service/src/diagnostic_messages.ts
Expand Up @@ -58,7 +58,7 @@ export const Diagnostic: Record<DiagnosticName, DiagnosticMessage> = {
},

call_target_not_callable: {
message: 'Call target is not callable',
message: `Call target '%1' has non-callable type '%2'.`,
kind: 'Error',
},

Expand Down
1 change: 1 addition & 0 deletions packages/language-service/src/diagnostics.ts
Expand Up @@ -40,6 +40,7 @@ export function getTemplateDiagnostics(ast: AstResult): ng.Diagnostic[] {
offset: template.span.start,
query: template.query,
members: template.members,
source: ast.template.source,
});
}

Expand Down
23 changes: 13 additions & 10 deletions packages/language-service/src/expression_diagnostics.ts
Expand Up @@ -21,6 +21,7 @@ export interface DiagnosticTemplateInfo {
members: SymbolTable;
htmlAst: Node[];
templateAst: TemplateAst[];
source: string;
}

export function getTemplateExpressionDiagnostics(info: DiagnosticTemplateInfo): ng.Diagnostic[] {
Expand Down Expand Up @@ -103,7 +104,7 @@ function getVarDeclarations(
// that have been declared so far are also in scope.
info.query.createSymbolTable(results),
]);
symbol = refinedVariableType(variable.value, symbolsInScope, info.query, current);
symbol = refinedVariableType(variable.value, symbolsInScope, info, current);
}
results.push({
name: variable.name,
Expand Down Expand Up @@ -143,11 +144,11 @@ function getVariableTypeFromDirectiveContext(
* case for `ngFor` and `ngIf`. If resolution fails, return the `any` type.
* @param value variable value name
* @param mergedTable symbol table for all variables in scope
* @param query
* @param info available template information
* @param templateElement
*/
function refinedVariableType(
value: string, mergedTable: SymbolTable, query: SymbolQuery,
value: string, mergedTable: SymbolTable, info: DiagnosticTemplateInfo,
templateElement: EmbeddedTemplateAst): Symbol {
if (value === '$implicit') {
// Special case: ngFor directive
Expand All @@ -159,9 +160,10 @@ function refinedVariableType(
const ngForOfBinding = ngForDirective.inputs.find(i => i.directiveName == 'ngForOf');
if (ngForOfBinding) {
// Check if there is a known type for the ngFor binding.
const bindingType = new AstType(mergedTable, query, {}).getType(ngForOfBinding.value);
const bindingType =
new AstType(mergedTable, info.query, {}, info.source).getType(ngForOfBinding.value);
if (bindingType) {
const result = query.getElementType(bindingType);
const result = info.query.getElementType(bindingType);
if (result) {
return result;
}
Expand All @@ -184,7 +186,8 @@ function refinedVariableType(
const ngIfBinding = ngIfDirective.inputs.find(i => i.directiveName === 'ngIf');
if (ngIfBinding) {
// Check if there is a known type bound to the ngIf input.
const bindingType = new AstType(mergedTable, query, {}).getType(ngIfBinding.value);
const bindingType =
new AstType(mergedTable, info.query, {}, info.source).getType(ngIfBinding.value);
if (bindingType) {
return bindingType;
}
Expand All @@ -193,7 +196,7 @@ function refinedVariableType(
}

// We can't do better, return any
return query.getBuiltinType(BuiltinType.Any);
return info.query.getBuiltinType(BuiltinType.Any);
}

function getEventDeclaration(
Expand Down Expand Up @@ -338,9 +341,9 @@ class ExpressionDiagnosticsVisitor extends RecursiveTemplateAstVisitor {
return ast.sourceSpan.start.offset;
}

private diagnoseExpression(ast: AST, offset: number, event: boolean) {
const scope = this.getExpressionScope(this.path, event);
const analyzer = new AstType(scope, this.info.query, {event});
private diagnoseExpression(ast: AST, offset: number, inEvent: boolean) {
const scope = this.getExpressionScope(this.path, inEvent);
const analyzer = new AstType(scope, this.info.query, {inEvent}, this.info.source);
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't have to pass in the entire template, because the AST we got from the TemplateAst is actually ASTWithSource, so we already have the expression string here. This info combined with the span of the expression node should be sufficient for us to retrieve the string of interest.
Before we do that though, it might be better to update the type for all ASTs in template_ast.ts from AST to ASTWithSource. Please let me know if you need more clarification, thank you!

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure this will work, because AstType may visit child nodes of the AST, which may not be ASTWithSources. We could pass the source of just the expression, but I am weary of this it would require using more relative spans. What do you think?

for (const diagnostic of analyzer.getDiagnostics(ast)) {
diagnostic.span = this.absSpan(diagnostic.span, offset);
this.diagnostics.push(diagnostic);
Expand Down
21 changes: 16 additions & 5 deletions packages/language-service/src/expression_type.ts
Expand Up @@ -12,21 +12,21 @@ import {Diagnostic, createDiagnostic} from './diagnostic_messages';
import {BuiltinType, Signature, Symbol, SymbolQuery, SymbolTable} from './symbols';
import * as ng from './types';

export interface ExpressionDiagnosticsContext { event?: boolean; }
export interface ExpressionDiagnosticsContext { inEvent?: boolean; }

// AstType calculatetype of the ast given AST element.
export class AstType implements AstVisitor {
private readonly diagnostics: ng.Diagnostic[] = [];

constructor(
private scope: SymbolTable, private query: SymbolQuery,
private context: ExpressionDiagnosticsContext) {}
private context: ExpressionDiagnosticsContext, private source: string) {}

getType(ast: AST): Symbol { return ast.visit(this); }

getDiagnostics(ast: AST): ng.Diagnostic[] {
const type: Symbol = ast.visit(this);
if (this.context.event && type.callable) {
if (this.context.inEvent && type.callable) {
this.diagnostics.push(
createDiagnostic(ast.span, Diagnostic.callable_expression_expected_method_call));
}
Expand Down Expand Up @@ -206,14 +206,16 @@ export class AstType implements AstVisitor {
const args = ast.args.map(arg => this.getType(arg));
const target = this.getType(ast.target !);
Copy link
Contributor

Choose a reason for hiding this comment

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

ast.target is of type AST here, but really it's a MethodCall AST, of which the receiver is an ImplicitReceiver.
The MethodCall AST contains the name of the function call, so it looks like a better fix here is to make a safe cast to retrieve the name.

Alternatively, we could fix the typings upstream. Right now, many parameters are just typed AST. They should have been more specific.

If we go with the cast, we'll have to account for the other case which is a PropertyRead receiver. Please see next comment =)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, you're right. I'll see if we can type the ast specially.

Copy link
Member Author

Choose a reason for hiding this comment

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

The problem with pulling out the name is it only contains the name for the most local AST. So for something like title.toUpperCase()(), the property read AST name would only give us toUpperCase, and we would have to continue backing up and reconstruct what we think the source code looks like to get title.toUpperCase(). For this reason, I think it's better to parse out the source code from the AST span.

if (!target || !target.callable) {
this.diagnostics.push(createDiagnostic(ast.span, Diagnostic.call_target_not_callable));
this.diagnostics.push(createDiagnostic(
ast.span, Diagnostic.call_target_not_callable, this.sourceOf(ast.target !), target.name));
return this.anyType;
}
const signature = target.selectSignature(args);
if (signature) {
return signature.result;
}
// TODO: Consider a better error message here.
// TODO: Consider a better error message here. See `typescript_symbols#selectSignature` for more
// details.
this.diagnostics.push(
createDiagnostic(ast.span, Diagnostic.unable_to_resolve_compatible_call_signature));
return this.anyType;
Expand Down Expand Up @@ -360,6 +362,15 @@ export class AstType implements AstVisitor {
return this.resolvePropertyRead(this.query.getNonNullableType(this.getType(ast.receiver)), ast);
}

/**
* Gets the source of an expession AST.
* The AST's sourceSpan is relative to the start of the template source code, which is contained
* at this.source.
*/
private sourceOf(ast: AST): string {
return this.source.substring(ast.sourceSpan.start, ast.sourceSpan.end);
}

private _anyType: Symbol|undefined;
private get anyType(): Symbol {
let result = this._anyType;
Expand Down
22 changes: 13 additions & 9 deletions packages/language-service/src/expressions.ts
Expand Up @@ -8,8 +8,7 @@

import {AST, ASTWithSource, AstPath as AstPathBase, RecursiveAstVisitor} from '@angular/compiler';
import {AstType} from './expression_type';

import {BuiltinType, Span, Symbol, SymbolQuery, SymbolTable} from './types';
import {BuiltinType, Span, Symbol, SymbolTable, TemplateSource} from './types';
import {inSpan} from './utils';

type AstPath = AstPathBase<AST>;
Expand Down Expand Up @@ -38,13 +37,16 @@ function findAstAt(ast: AST, position: number, excludeEmpty: boolean = false): A
}

export function getExpressionCompletions(
scope: SymbolTable, ast: AST, position: number, query: SymbolQuery): Symbol[]|undefined {
scope: SymbolTable, ast: AST, position: number, templateInfo: TemplateSource): Symbol[]|
undefined {
const path = findAstAt(ast, position);
if (path.empty) return undefined;
const tail = path.tail !;
let result: SymbolTable|undefined = scope;

function getType(ast: AST): Symbol { return new AstType(scope, query, {}).getType(ast); }
function getType(ast: AST): Symbol {
return new AstType(scope, templateInfo.query, {}, templateInfo.source).getType(ast);
}

// If the completion request is in a not in a pipe or property access then the global scope
// (that is the scope of the implicit receiver) is the right scope as the user is typing the
Expand All @@ -66,7 +68,7 @@ export function getExpressionCompletions(
if (position >= ast.exp.span.end &&
(!ast.args || !ast.args.length || position < (<AST>ast.args[0]).span.start)) {
// We are in a position a pipe name is expected.
result = query.getPipes();
result = templateInfo.query.getPipes();
}
},
visitPrefixNot(ast) {},
Expand All @@ -81,7 +83,7 @@ export function getExpressionCompletions(
},
visitQuote(ast) {
// For a quote, return the members of any (if there are any).
result = query.getBuiltinType(BuiltinType.Any).members();
result = templateInfo.query.getBuiltinType(BuiltinType.Any).members();
},
visitSafeMethodCall(ast) {
const receiverType = getType(ast.receiver);
Expand All @@ -106,12 +108,14 @@ export function getExpressionCompletions(
*/
export function getExpressionSymbol(
scope: SymbolTable, ast: AST, position: number,
query: SymbolQuery): {symbol: Symbol, span: Span}|undefined {
templateInfo: TemplateSource): {symbol: Symbol, span: Span}|undefined {
const path = findAstAt(ast, position, /* excludeEmpty */ true);
if (path.empty) return undefined;
const tail = path.tail !;

function getType(ast: AST): Symbol { return new AstType(scope, query, {}).getType(ast); }
function getType(ast: AST): Symbol {
return new AstType(scope, templateInfo.query, {}, templateInfo.source).getType(ast);
}

let symbol: Symbol|undefined = undefined;
let span: Span|undefined = undefined;
Expand Down Expand Up @@ -139,7 +143,7 @@ export function getExpressionSymbol(
visitPipe(ast) {
if (inSpan(position, ast.nameSpan, /* exclusive */ true)) {
// We are in a position a pipe name is expected.
const pipes = query.getPipes();
const pipes = templateInfo.query.getPipes();
symbol = pipes.get(ast.name);

// `nameSpan` is an absolute span, but the span expected by the result of this method is
Expand Down
9 changes: 4 additions & 5 deletions packages/language-service/src/locate_symbol.ts
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AST, Attribute, BoundDirectivePropertyAst, CssSelector, DirectiveAst, ElementAst, EmbeddedTemplateAst, ExpressionBinding, RecursiveTemplateAstVisitor, SelectorMatcher, StaticSymbol, TemplateAst, TemplateAstPath, VariableBinding, templateVisitAll, tokenReference} from '@angular/compiler';
import {AST, Attribute, BoundDirectivePropertyAst, CssSelector, DirectiveAst, ElementAst, EmbeddedTemplateAst, RecursiveTemplateAstVisitor, SelectorMatcher, StaticSymbol, TemplateAst, TemplateAstPath, VariableBinding, templateVisitAll, tokenReference} from '@angular/compiler';
import * as tss from 'typescript/lib/tsserverlibrary';

import {AstResult} from './common';
Expand Down Expand Up @@ -73,7 +73,7 @@ function locateSymbol(ast: TemplateAst, path: TemplateAstPath, info: AstResult):
} else {
const dinfo = diagnosticInfoFromTemplateInfo(info);
const scope = getExpressionScope(dinfo, path);
result = getExpressionSymbol(scope, ast, templatePosition, info.template.query);
result = getExpressionSymbol(scope, ast, templatePosition, info.template);
}
if (result) {
symbol = result.symbol;
Expand Down Expand Up @@ -149,8 +149,7 @@ function locateSymbol(ast: TemplateAst, path: TemplateAstPath, info: AstResult):
if (inSpan(expressionPosition, ast.value.span)) {
const dinfo = diagnosticInfoFromTemplateInfo(info);
const scope = getExpressionScope(dinfo, path);
const result =
getExpressionSymbol(scope, ast.value, templatePosition, info.template.query);
const result = getExpressionSymbol(scope, ast.value, templatePosition, info.template);
if (result) {
symbol = result.symbol;
span = offsetSpan(result.span, ast.sourceSpan.start.offset);
Expand Down Expand Up @@ -217,7 +216,7 @@ function getSymbolInMicrosyntax(info: AstResult, path: TemplateAstPath, attribut
if (inSpan(path.position, tb.value?.ast.sourceSpan)) {
const dinfo = diagnosticInfoFromTemplateInfo(info);
const scope = getExpressionScope(dinfo, path);
result = getExpressionSymbol(scope, tb.value !, path.position, info.template.query);
result = getExpressionSymbol(scope, tb.value !, path.position, info.template);
} else if (inSpan(path.position, tb.sourceSpan)) {
const template = path.first(EmbeddedTemplateAst);
if (template) {
Expand Down
5 changes: 4 additions & 1 deletion packages/language-service/src/typescript_symbols.ts
Expand Up @@ -220,7 +220,10 @@ function signaturesOf(type: ts.Type, context: TypeContext): Signature[] {

function selectSignature(type: ts.Type, context: TypeContext, types: Symbol[]): Signature|
undefined {
// TODO: Do a better job of selecting the right signature.
// TODO: Do a better job of selecting the right signature. TypeScript does not currently support a
// Type Relationship API (see https://github.com/angular/vscode-ng-language-service/issues/143).
// Consider creating a TypeCheckBlock host in the language service that may also act as a
// scratchpad for type comparisons.
const signatures = type.getCallSignatures();
return signatures.length ? new SignatureWrapper(signatures[0], context) : undefined;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/language-service/src/utils.ts
Expand Up @@ -97,7 +97,8 @@ export function diagnosticInfoFromTemplateInfo(info: AstResult): DiagnosticTempl
query: info.template.query,
members: info.template.members,
htmlAst: info.htmlAst,
templateAst: info.templateAst
templateAst: info.templateAst,
source: info.template.source,
};
}

Expand Down
15 changes: 15 additions & 0 deletions packages/language-service/test/diagnostics_spec.ts
Expand Up @@ -1023,4 +1023,19 @@ describe('diagnostics', () => {
expect(content.substring(start !, start ! + length !)).toBe(expression);
}
});

describe('function calls', () => {
it('should report error for non-callable function call', () => {
mockHost.override(TEST_TEMPLATE, `
<p>{{myClick()()}}</p>
`);

const content = mockHost.readFile(TEST_TEMPLATE) !;
const diags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(diags.length).toBe(1);
const {messageText, start, length} = diags[0] !;
expect(messageText).toBe(`Call target 'myClick()' has non-callable type 'void'.`);
expect(content.substring(start !, start ! + length !)).toBe('myClick()()');
});
});
});
5 changes: 3 additions & 2 deletions packages/language-service/test/mocks.ts
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AotSummaryResolver, CompileMetadataResolver, CompilerConfig, DEFAULT_INTERPOLATION_CONFIG, DirectiveNormalizer, DirectiveResolver, DomElementSchemaRegistry, HtmlParser, I18NHtmlParser, InterpolationConfig, JitSummaryResolver, Lexer, NgAnalyzedModules, NgModuleResolver, ParseTreeResult, Parser, PipeResolver, ResourceLoader, StaticReflector, StaticSymbol, StaticSymbolCache, StaticSymbolResolver, StaticSymbolResolverHost, SummaryResolver, TemplateParser, analyzeNgModules, createOfflineCompileUrlResolver} from '@angular/compiler';
import {AotSummaryResolver, CompileMetadataResolver, CompilerConfig, DirectiveNormalizer, DirectiveResolver, DomElementSchemaRegistry, HtmlParser, I18NHtmlParser, JitSummaryResolver, Lexer, NgAnalyzedModules, NgModuleResolver, ParseTreeResult, Parser, PipeResolver, ResourceLoader, StaticReflector, StaticSymbol, StaticSymbolCache, StaticSymbolResolver, StaticSymbolResolverHost, TemplateParser, analyzeNgModules, createOfflineCompileUrlResolver} from '@angular/compiler';
import {Directory, MockAotContext} from '@angular/compiler-cli/test/mocks';
import {setup} from '@angular/compiler-cli/test/test_support';
import {ViewEncapsulation, ɵConsole as Console} from '@angular/core';
Expand Down Expand Up @@ -238,7 +238,8 @@ export function getDiagnosticTemplateInfo(
fileName: templateFile,
offset: 0, query, members,
htmlAst: compiledTemplate.htmlAst,
templateAst: compiledTemplate.templateAst
templateAst: compiledTemplate.templateAst,
source: sourceFile.text,
};
}
}
Expand Down