From 3f21382d43cafa1e32162e58adabd22d5c3709ed Mon Sep 17 00:00:00 2001 From: Harttle Date: Tue, 3 Jan 2023 01:34:03 +0800 Subject: [PATCH] feat: support `not` operator, #575 --- src/parser/tokenizer.ts | 20 +++++++++--------- src/render/expression.ts | 20 +++++++++--------- src/render/operator.ts | 7 +++++-- src/tokens/operator-token.ts | 37 +++++++++++++++++++++++++--------- test/e2e/issues.ts | 12 +++++++++++ test/unit/parser/tokenizer.ts | 4 +++- test/unit/render/expression.ts | 7 +++++++ 7 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 335f8197c0..459fa3ebfa 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -26,20 +26,18 @@ export class Tokenizer { } * readExpressionTokens (): IterableIterator { - const operand = this.readValue() - if (!operand) return - - yield operand - while (this.p < this.N) { const operator = this.readOperator() - if (!operator) return - + if (operator) { + yield operator + continue + } const operand = this.readValue() - if (!operand) return - - yield operator - yield operand + if (operand) { + yield operand + continue + } + return } } readOperator (): OperatorToken | undefined { diff --git a/src/render/expression.ts b/src/render/expression.ts index 6357d51412..4807e3e307 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -1,8 +1,8 @@ -import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken } from '../tokens' +import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken, OperatorType, operatorTypes } from '../tokens' import { isQuotedToken, isWordToken, isNumberToken, isLiteralToken, isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, literalValues, assert } from '../util' import { parseStringLiteral } from '../parser' -import { Context } from '../context' -import { Operators } from '../render' +import type { Context } from '../context' +import type { UnaryOperatorHandler } from '../render' export class Expression { private postfix: Token[] @@ -16,8 +16,13 @@ export class Expression { for (const token of this.postfix) { if (isOperatorToken(token)) { const r = operands.pop() - const l = operands.pop() - const result = yield evalOperatorToken(ctx.opts.operators, token, l, r, ctx) + let result + if (operatorTypes[token.operator] === OperatorType.Unary) { + result = yield (ctx.opts.operators[token.operator] as UnaryOperatorHandler)(r, ctx) + } else { + const l = operands.pop() + result = yield ctx.opts.operators[token.operator](l, r, ctx) + } operands.push(result) } else { operands.push(yield evalToken(token, ctx, lenient && this.postfix.length === 1)) @@ -58,11 +63,6 @@ export function evalQuotedToken (token: QuotedToken) { return parseStringLiteral(token.getText()) } -function evalOperatorToken (operators: Operators, token: OperatorToken, lhs: any, rhs: any, ctx: Context) { - const impl = operators[token.operator] - return impl(lhs, rhs, ctx) -} - function evalLiteralToken (token: LiteralToken) { return literalValues[token.literal] } diff --git a/src/render/operator.ts b/src/render/operator.ts index 2391a1ee47..49978ed7dd 100644 --- a/src/render/operator.ts +++ b/src/render/operator.ts @@ -1,9 +1,11 @@ import { isComparable } from '../drop/comparable' import { Context } from '../context' import { isFunction, toValue } from '../util' -import { isTruthy } from '../render/boolean' +import { isFalsy, isTruthy } from '../render/boolean' -export type OperatorHandler = (lhs: any, rhs: any, ctx: Context) => boolean; +export type UnaryOperatorHandler = (operand: any, ctx: Context) => boolean; +export type BinaryOperatorHandler = (lhs: any, rhs: any, ctx: Context) => boolean; +export type OperatorHandler = UnaryOperatorHandler | BinaryOperatorHandler; export type Operators = Record export const defaultOperators: Operators = { @@ -42,6 +44,7 @@ export const defaultOperators: Operators = { r = toValue(r) return l && isFunction(l.indexOf) ? l.indexOf(r) > -1 : false }, + 'not': (v: any, ctx: Context) => isFalsy(toValue(v), ctx), 'and': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) && isTruthy(toValue(r), ctx), 'or': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) || isTruthy(toValue(r), ctx) } diff --git a/src/tokens/operator-token.ts b/src/tokens/operator-token.ts index 2efa3d9383..ae6724e223 100644 --- a/src/tokens/operator-token.ts +++ b/src/tokens/operator-token.ts @@ -1,18 +1,37 @@ import { Token } from './token' import { TokenKind } from '../parser' -export const precedence = { - '==': 1, - '!=': 1, - '>': 1, - '<': 1, - '>=': 1, - '<=': 1, - 'contains': 1, +export const enum OperatorType { + Binary, + Unary +} + +export const operatorPrecedences = { + '==': 2, + '!=': 2, + '>': 2, + '<': 2, + '>=': 2, + '<=': 2, + 'contains': 2, + 'not': 1, 'and': 0, 'or': 0 } +export const operatorTypes = { + '==': OperatorType.Binary, + '!=': OperatorType.Binary, + '>': OperatorType.Binary, + '<': OperatorType.Binary, + '>=': OperatorType.Binary, + '<=': OperatorType.Binary, + 'contains': OperatorType.Binary, + 'not': OperatorType.Unary, + 'and': OperatorType.Binary, + 'or': OperatorType.Binary +} + export class OperatorToken extends Token { public operator: string public constructor ( @@ -26,6 +45,6 @@ export class OperatorToken extends Token { } getPrecedence () { const key = this.getText() - return key in precedence ? precedence[key] : 1 + return key in operatorPrecedences ? operatorPrecedences[key] : 1 } } diff --git a/test/e2e/issues.ts b/test/e2e/issues.ts index 0e4f332f92..43e70e3b6e 100644 --- a/test/e2e/issues.ts +++ b/test/e2e/issues.ts @@ -377,4 +377,16 @@ describe('Issues', function () { const html = await liquid.parseAndRender(tpl) expect(html).to.match(/\w+, January \d+, 2023 at \d+:\d\d [ap]m [-+]\d\d\d\d/) }) + it('#575 Add support for Not operator', async () => { + const liquid = new Liquid() + const tpl = ` + {% if link and not button %} + Lot more code here + {% else %} +
Lot more code here
+ {% endif %}` + const ctx = { link: 'https://example.com', button: false } + const html = await liquid.parseAndRender(tpl, ctx) + expect(html.trim()).to.equal('Lot more code here') + }) }) diff --git a/test/unit/parser/tokenizer.ts b/test/unit/parser/tokenizer.ts index 996191be86..78a8f5c035 100644 --- a/test/unit/parser/tokenizer.ts +++ b/test/unit/parser/tokenizer.ts @@ -397,9 +397,11 @@ describe('Tokenizer', function () { it('should read expression `a ==`', () => { const exp = [...new Tokenizer('a ==').readExpressionTokens()] - expect(exp).to.have.lengthOf(1) + expect(exp).to.have.lengthOf(2) expect(exp[0]).to.be.instanceOf(PropertyAccessToken) expect(exp[0].getText()).to.deep.equal('a') + expect(exp[1]).to.be.instanceOf(OperatorToken) + expect(exp[1].getText()).to.deep.equal('==') }) it('should read expression `a==b`', () => { const exp = new Tokenizer('a==b').readExpressionTokens() diff --git a/test/unit/render/expression.ts b/test/unit/render/expression.ts index 1cabb8be46..972d82fada 100644 --- a/test/unit/render/expression.ts +++ b/test/unit/render/expression.ts @@ -154,6 +154,13 @@ describe('Expression', function () { const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } }) expect(await toPromise(create('obj[keys["what\'s this"]]').evaluate(ctx, false))).to.equal('FOO') }) + it('should support not', async function () { + expect(await toPromise(create('not 1 < 2').evaluate(ctx))).to.equal(false) + }) + it('not should have higher precedence than and/or', async function () { + expect(await toPromise(create('not 1 < 2 or not 1 > 2').evaluate(ctx))).to.equal(true) + expect(await toPromise(create('not 1 < 2 and not 1 > 2').evaluate(ctx))).to.equal(false) + }) }) describe('sync', function () {