feat(compiler): support unary operators for more accurate type checking (#37918)

Prior to this change, the unary + and - operators would be parsed as `x - 0`
and `0 - x` respectively. The runtime semantics of these expressions are
equivalent, however they may introduce inaccurate template type checking
errors as the literal type is lost, for example:

```ts
@Component({
  template: `<button [disabled]="isAdjacent(-1)"></button>`
})
export class Example {
  isAdjacent(direction: -1 | 1): boolean { return false; }
}
```

would incorrectly report a type-check error:

> error TS2345: Argument of type 'number' is not assignable to parameter
  of type '-1 | 1'.

Additionally, the translated expression for the unary + operator would be
considered as arithmetic expression with an incompatible left-hand side:

> error TS2362: The left-hand side of an arithmetic operation must be of
  type 'any', 'number', 'bigint' or an enum type.

To resolve this issues, the implicit transformation should be avoided.
This commit adds a new unary AST node to represent these expressions,
allowing for more accurate type-checking.

Fixes #20845
Fixes #36178

PR Close #37918
This commit is contained in:
JoostK
2020-07-04 01:52:40 +02:00
committed by Misko Hevery
parent e7da4040d6
commit 874792dc43
23 changed files with 313 additions and 23 deletions

View File

@ -330,6 +330,26 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
private bindingId: string, private interpolationFunction: InterpolationFunction|undefined,
private baseSourceSpan?: ParseSourceSpan, private implicitReceiverAccesses?: Set<string>) {}
visitUnary(ast: cdAst.Unary, mode: _Mode): any {
let op: o.UnaryOperator;
switch (ast.operator) {
case '+':
op = o.UnaryOperator.Plus;
break;
case '-':
op = o.UnaryOperator.Minus;
break;
default:
throw new Error(`Unsupported operator ${ast.operator}`);
}
return convertToStatementIfNeeded(
mode,
new o.UnaryOperatorExpr(
op, this._visit(ast.expr, _Mode.Expression), undefined,
this.convertSourceSpan(ast.span)));
}
visitBinary(ast: cdAst.Binary, mode: _Mode): any {
let op: o.BinaryOperator;
switch (ast.operation) {
@ -710,6 +730,9 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
return (this._nodeMap.get(ast) || ast).visit(visitor);
};
return ast.visit({
visitUnary(ast: cdAst.Unary) {
return null;
},
visitBinary(ast: cdAst.Binary) {
return null;
},
@ -784,6 +807,9 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
return ast.some(ast => visit(visitor, ast));
};
return ast.visit({
visitUnary(ast: cdAst.Unary): boolean {
return visit(this, ast.expr);
},
visitBinary(ast: cdAst.Binary): boolean {
return visit(this, ast.left) || visit(this, ast.right);
},

View File

@ -330,6 +330,7 @@ class KeyVisitor implements o.ExpressionVisitor {
visitAssertNotNullExpr = invalid;
visitCastExpr = invalid;
visitFunctionExpr = invalid;
visitUnaryOperatorExpr = invalid;
visitBinaryOperatorExpr = invalid;
visitReadPropExpr = invalid;
visitReadKeyExpr = invalid;

View File

@ -227,6 +227,52 @@ export class Binary extends AST {
}
}
/**
* For backwards compatibility reasons, `Unary` inherits from `Binary` and mimics the binary AST
* node that was originally used. This inheritance relation can be deleted in some future major,
* after consumers have been given a chance to fully support Unary.
*/
export class Unary extends Binary {
// Redeclare the properties that are inherited from `Binary` as `never`, as consumers should not
// depend on these fields when operating on `Unary`.
left: never;
right: never;
operation: never;
/**
* Creates a unary minus expression "-x", represented as `Binary` using "0 - x".
*/
static createMinus(span: ParseSpan, sourceSpan: AbsoluteSourceSpan, expr: AST): Unary {
return new Unary(
span, sourceSpan, '-', expr, '-', new LiteralPrimitive(span, sourceSpan, 0), expr);
}
/**
* Creates a unary plus expression "+x", represented as `Binary` using "x - 0".
*/
static createPlus(span: ParseSpan, sourceSpan: AbsoluteSourceSpan, expr: AST): Unary {
return new Unary(
span, sourceSpan, '+', expr, '-', expr, new LiteralPrimitive(span, sourceSpan, 0));
}
/**
* During the deprecation period this constructor is private, to avoid consumers from creating
* a `Unary` with the fallback properties for `Binary`.
*/
private constructor(
span: ParseSpan, sourceSpan: AbsoluteSourceSpan, public operator: string, public expr: AST,
binaryOp: string, binaryLeft: AST, binaryRight: AST) {
super(span, sourceSpan, binaryOp, binaryLeft, binaryRight);
}
visit(visitor: AstVisitor, context: any = null): any {
if (visitor.visitUnary !== undefined) {
return visitor.visitUnary(this, context);
}
return visitor.visitBinary(this, context);
}
}
export class PrefixNot extends AST {
constructor(span: ParseSpan, sourceSpan: AbsoluteSourceSpan, public expression: AST) {
super(span, sourceSpan);
@ -361,6 +407,11 @@ export interface TemplateBindingIdentifier {
}
export interface AstVisitor {
/**
* The `visitUnary` method is declared as optional for backwards compatibility. In an upcoming
* major release, this method will be made required.
*/
visitUnary?(ast: Unary, context: any): any;
visitBinary(ast: Binary, context: any): any;
visitChain(ast: Chain, context: any): any;
visitConditional(ast: Conditional, context: any): any;
@ -398,6 +449,9 @@ export class RecursiveAstVisitor implements AstVisitor {
// to selectively visit the specified node.
ast.visit(this, context);
}
visitUnary(ast: Unary, context: any): any {
this.visit(ast.expr, context);
}
visitBinary(ast: Binary, context: any): any {
this.visit(ast.left, context);
this.visit(ast.right, context);
@ -527,6 +581,17 @@ export class AstTransformer implements AstVisitor {
return new LiteralMap(ast.span, ast.sourceSpan, ast.keys, this.visitAll(ast.values));
}
visitUnary(ast: Unary, context: any): AST {
switch (ast.operator) {
case '+':
return Unary.createPlus(ast.span, ast.sourceSpan, ast.expr.visit(this));
case '-':
return Unary.createMinus(ast.span, ast.sourceSpan, ast.expr.visit(this));
default:
throw new Error(`Unknown unary operator ${ast.operator}`);
}
}
visitBinary(ast: Binary, context: any): AST {
return new Binary(
ast.span, ast.sourceSpan, ast.operation, ast.left.visit(this), ast.right.visit(this));
@ -665,6 +730,21 @@ export class AstMemoryEfficientTransformer implements AstVisitor {
return ast;
}
visitUnary(ast: Unary, context: any): AST {
const expr = ast.expr.visit(this);
if (expr !== ast.expr) {
switch (ast.operator) {
case '+':
return Unary.createPlus(ast.span, ast.sourceSpan, expr);
case '-':
return Unary.createMinus(ast.span, ast.sourceSpan, expr);
default:
throw new Error(`Unknown unary operator ${ast.operator}`);
}
}
return ast;
}
visitBinary(ast: Binary, context: any): AST {
const left = ast.left.visit(this);
const right = ast.right.visit(this);

View File

@ -10,7 +10,7 @@ import * as chars from '../chars';
import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/interpolation_config';
import {escapeRegExp} from '../util';
import {AbsoluteSourceSpan, AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Chain, Conditional, EmptyExpr, ExpressionBinding, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralMapKey, LiteralPrimitive, MethodCall, NonNullAssert, ParserError, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead, TemplateBinding, TemplateBindingIdentifier, VariableBinding} from './ast';
import {AbsoluteSourceSpan, AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Chain, Conditional, EmptyExpr, ExpressionBinding, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralMapKey, LiteralPrimitive, MethodCall, NonNullAssert, ParserError, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead, TemplateBinding, TemplateBindingIdentifier, Unary, VariableBinding} from './ast';
import {EOF, isIdentifier, isQuote, Lexer, Token, TokenType} from './lexer';
export class SplitInterpolation {
@ -591,22 +591,16 @@ export class _ParseAST {
if (this.next.type == TokenType.Operator) {
const start = this.inputIndex;
const operator = this.next.strValue;
const literalSpan = new ParseSpan(start, start);
const literalSourceSpan = literalSpan.toAbsolute(this.absoluteOffset);
let result: AST;
switch (operator) {
case '+':
this.advance();
result = this.parsePrefix();
return new Binary(
this.span(start), this.sourceSpan(start), '-', result,
new LiteralPrimitive(literalSpan, literalSourceSpan, 0));
return Unary.createPlus(this.span(start), this.sourceSpan(start), result);
case '-':
this.advance();
result = this.parsePrefix();
return new Binary(
this.span(start), this.sourceSpan(start), operator,
new LiteralPrimitive(literalSpan, literalSourceSpan, 0), result);
return Unary.createMinus(this.span(start), this.sourceSpan(start), result);
case '!':
this.advance();
result = this.parsePrefix();
@ -1059,6 +1053,8 @@ class SimpleExpressionChecker implements AstVisitor {
this.visitAll(ast.values, context);
}
visitUnary(ast: Unary, context: any) {}
visitBinary(ast: Binary, context: any) {}
visitPrefixNot(ast: PrefixNot, context: any) {}

View File

@ -420,6 +420,25 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex
abstract visitFunctionExpr(ast: o.FunctionExpr, ctx: EmitterVisitorContext): any;
abstract visitDeclareFunctionStmt(stmt: o.DeclareFunctionStmt, context: any): any;
visitUnaryOperatorExpr(ast: o.UnaryOperatorExpr, ctx: EmitterVisitorContext): any {
let opStr: string;
switch (ast.operator) {
case o.UnaryOperator.Plus:
opStr = '+';
break;
case o.UnaryOperator.Minus:
opStr = '-';
break;
default:
throw new Error(`Unknown operator ${ast.operator}`);
}
if (ast.parens) ctx.print(ast, `(`);
ctx.print(ast, opStr);
ast.expr.visitExpression(this, ctx);
if (ast.parens) ctx.print(ast, `)`);
return null;
}
visitBinaryOperatorExpr(ast: o.BinaryOperatorExpr, ctx: EmitterVisitorContext): any {
let opStr: string;
switch (ast.operator) {

View File

@ -100,6 +100,11 @@ export interface TypeVisitor {
///// Expressions
export enum UnaryOperator {
Minus,
Plus,
}
export enum BinaryOperator {
Equals,
NotEquals,
@ -753,6 +758,28 @@ export class FunctionExpr extends Expression {
}
export class UnaryOperatorExpr extends Expression {
constructor(
public operator: UnaryOperator, public expr: Expression, type?: Type|null,
sourceSpan?: ParseSourceSpan|null, public parens: boolean = true) {
super(type || NUMBER_TYPE, sourceSpan);
}
isEquivalent(e: Expression): boolean {
return e instanceof UnaryOperatorExpr && this.operator === e.operator &&
this.expr.isEquivalent(e.expr);
}
isConstant() {
return false;
}
visitExpression(visitor: ExpressionVisitor, context: any): any {
return visitor.visitUnaryOperatorExpr(this, context);
}
}
export class BinaryOperatorExpr extends Expression {
public lhs: Expression;
constructor(
@ -912,6 +939,7 @@ export interface ExpressionVisitor {
visitAssertNotNullExpr(ast: AssertNotNull, context: any): any;
visitCastExpr(ast: CastExpr, context: any): any;
visitFunctionExpr(ast: FunctionExpr, context: any): any;
visitUnaryOperatorExpr(ast: UnaryOperatorExpr, context: any): any;
visitBinaryOperatorExpr(ast: BinaryOperatorExpr, context: any): any;
visitReadPropExpr(ast: ReadPropExpr, context: any): any;
visitReadKeyExpr(ast: ReadKeyExpr, context: any): any;
@ -1292,6 +1320,13 @@ export class AstTransformer implements StatementVisitor, ExpressionVisitor {
context);
}
visitUnaryOperatorExpr(ast: UnaryOperatorExpr, context: any): any {
return this.transformExpr(
new UnaryOperatorExpr(
ast.operator, ast.expr.visitExpression(this, context), ast.type, ast.sourceSpan),
context);
}
visitBinaryOperatorExpr(ast: BinaryOperatorExpr, context: any): any {
return this.transformExpr(
new BinaryOperatorExpr(
@ -1517,6 +1552,10 @@ export class RecursiveAstVisitor implements StatementVisitor, ExpressionVisitor
this.visitAllStatements(ast.statements, context);
return this.visitExpression(ast, context);
}
visitUnaryOperatorExpr(ast: UnaryOperatorExpr, context: any): any {
ast.expr.visitExpression(this, context);
return this.visitExpression(ast, context);
}
visitBinaryOperatorExpr(ast: BinaryOperatorExpr, context: any): any {
ast.lhs.visitExpression(this, context);
ast.rhs.visitExpression(this, context);
@ -1730,6 +1769,12 @@ export function literalMap(
values.map(e => new LiteralMapEntry(e.key, e.value, e.quoted)), type, null);
}
export function unary(
operator: UnaryOperator, expr: Expression, type?: Type,
sourceSpan?: ParseSourceSpan|null): UnaryOperatorExpr {
return new UnaryOperatorExpr(operator, expr, type, sourceSpan);
}
export function not(expr: Expression, sourceSpan?: ParseSourceSpan|null): NotExpr {
return new NotExpr(expr, sourceSpan);
}

View File

@ -282,6 +282,18 @@ class StatementInterpreter implements o.StatementVisitor, o.ExpressionVisitor {
}
return null;
}
visitUnaryOperatorExpr(ast: o.UnaryOperatorExpr, ctx: _ExecutionContext): any {
const rhs = () => ast.expr.visitExpression(this, ctx);
switch (ast.operator) {
case o.UnaryOperator.Plus:
return +rhs();
case o.UnaryOperator.Minus:
return -rhs();
default:
throw new Error(`Unknown operator ${ast.operator}`);
}
}
visitBinaryOperatorExpr(ast: o.BinaryOperatorExpr, ctx: _ExecutionContext): any {
const lhs = () => ast.lhs.visitExpression(this, ctx);
const rhs = () => ast.rhs.visitExpression(this, ctx);

View File

@ -34,11 +34,11 @@ describe('parser', () => {
checkAction('undefined');
});
it('should parse unary - expressions', () => {
checkAction('-1', '0 - 1');
checkAction('+1', '1 - 0');
checkAction(`-'1'`, `0 - "1"`);
checkAction(`+'1'`, `"1" - 0`);
it('should parse unary - and + expressions', () => {
checkAction('-1', '-1');
checkAction('+1', '+1');
checkAction(`-'1'`, `-"1"`);
checkAction(`+'1'`, `+"1"`);
});
it('should parse unary ! expressions', () => {

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AbsoluteSourceSpan, AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Chain, Conditional, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, NonNullAssert, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead} from '../../../src/expression_parser/ast';
import {AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Chain, Conditional, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, NonNullAssert, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead, Unary} from '../../../src/expression_parser/ast';
import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../../src/ml_parser/interpolation_config';
class Unparser implements AstVisitor {
@ -35,6 +35,11 @@ class Unparser implements AstVisitor {
this._visit(ast.value);
}
visitUnary(ast: Unary, context: any) {
this._expression += ast.operator;
this._visit(ast.expr);
}
visitBinary(ast: Binary, context: any) {
this._visit(ast.left);
this._expression += ` ${ast.operation} `;

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, Binary, BindingPipe, Chain, Conditional, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead} from '../../../src/expression_parser/ast';
import {AST, Binary, BindingPipe, Chain, Conditional, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead, Unary} from '../../../src/expression_parser/ast';
import {unparse} from './unparser';
@ -34,6 +34,10 @@ class ASTValidator extends RecursiveAstVisitor {
this.parentSpan = oldParent;
}
visitUnary(ast: Unary, context: any): any {
this.validate(ast, () => super.visitUnary(ast, context));
}
visitBinary(ast: Binary, context: any): any {
this.validate(ast, () => super.visitBinary(ast, context));
}

View File

@ -138,6 +138,8 @@ const externalModuleIdentifier = new o.ExternalReference(anotherModuleUrl, 'some
const lhs = o.variable('lhs');
const rhs = o.variable('rhs');
expect(emitStmt(o.not(someVar).toStmt())).toEqual('!someVar;');
expect(emitStmt(o.unary(o.UnaryOperator.Minus, someVar).toStmt())).toEqual('(-someVar);');
expect(emitStmt(o.unary(o.UnaryOperator.Plus, someVar).toStmt())).toEqual('(+someVar);');
expect(emitStmt(o.assertNotNull(someVar).toStmt())).toEqual('someVar;');
expect(
emitStmt(someVar.conditional(o.variable('trueCase'), o.variable('falseCase')).toStmt()))

View File

@ -191,6 +191,8 @@ const externalModuleIdentifier = new o.ExternalReference(anotherModuleUrl, 'some
const rhs = o.variable('rhs');
expect(emitStmt(someVar.cast(o.INT_TYPE).toStmt())).toEqual('(<number>someVar);');
expect(emitStmt(o.not(someVar).toStmt())).toEqual('!someVar;');
expect(emitStmt(o.unary(o.UnaryOperator.Minus, someVar).toStmt())).toEqual('(-someVar);');
expect(emitStmt(o.unary(o.UnaryOperator.Plus, someVar).toStmt())).toEqual('(+someVar);');
expect(emitStmt(o.assertNotNull(someVar).toStmt())).toEqual('someVar!;');
expect(
emitStmt(someVar.conditional(o.variable('trueCase'), o.variable('falseCase')).toStmt()))

View File

@ -1656,7 +1656,7 @@ Reference "#a" is defined several times ("<div #a></div><div [ERROR ->]#a></div>
expect(humanizeTplAst(parse('<div *ngIf="-1">', [ngIf]))).toEqual([
[EmbeddedTemplateAst],
[DirectiveAst, ngIf],
[BoundDirectivePropertyAst, 'ngIf', '0 - 1'],
[BoundDirectivePropertyAst, 'ngIf', '-1'],
[ElementAst, 'div'],
]);
});

View File

@ -30,6 +30,10 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Templ
this.recordAst(ast);
this.visitAll([ast.ast], null);
}
visitUnary(ast: e.Unary) {
this.recordAst(ast);
super.visitUnary(ast, null);
}
visitBinary(ast: e.Binary) {
this.recordAst(ast);
super.visitBinary(ast, null);