Skip to content

Commit 3f21382

Browse files
committed
feat: support not operator, #575
1 parent aafaa0b commit 3f21382

File tree

7 files changed

+74
-33
lines changed

7 files changed

+74
-33
lines changed

src/parser/tokenizer.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,18 @@ export class Tokenizer {
2626
}
2727

2828
* readExpressionTokens (): IterableIterator<Token> {
29-
const operand = this.readValue()
30-
if (!operand) return
31-
32-
yield operand
33-
3429
while (this.p < this.N) {
3530
const operator = this.readOperator()
36-
if (!operator) return
37-
31+
if (operator) {
32+
yield operator
33+
continue
34+
}
3835
const operand = this.readValue()
39-
if (!operand) return
40-
41-
yield operator
42-
yield operand
36+
if (operand) {
37+
yield operand
38+
continue
39+
}
40+
return
4341
}
4442
}
4543
readOperator (): OperatorToken | undefined {

src/render/expression.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken } from '../tokens'
1+
import { RangeToken, OperatorToken, Token, LiteralToken, NumberToken, PropertyAccessToken, QuotedToken, OperatorType, operatorTypes } from '../tokens'
22
import { isQuotedToken, isWordToken, isNumberToken, isLiteralToken, isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, literalValues, assert } from '../util'
33
import { parseStringLiteral } from '../parser'
4-
import { Context } from '../context'
5-
import { Operators } from '../render'
4+
import type { Context } from '../context'
5+
import type { UnaryOperatorHandler } from '../render'
66

77
export class Expression {
88
private postfix: Token[]
@@ -16,8 +16,13 @@ export class Expression {
1616
for (const token of this.postfix) {
1717
if (isOperatorToken(token)) {
1818
const r = operands.pop()
19-
const l = operands.pop()
20-
const result = yield evalOperatorToken(ctx.opts.operators, token, l, r, ctx)
19+
let result
20+
if (operatorTypes[token.operator] === OperatorType.Unary) {
21+
result = yield (ctx.opts.operators[token.operator] as UnaryOperatorHandler)(r, ctx)
22+
} else {
23+
const l = operands.pop()
24+
result = yield ctx.opts.operators[token.operator](l, r, ctx)
25+
}
2126
operands.push(result)
2227
} else {
2328
operands.push(yield evalToken(token, ctx, lenient && this.postfix.length === 1))
@@ -58,11 +63,6 @@ export function evalQuotedToken (token: QuotedToken) {
5863
return parseStringLiteral(token.getText())
5964
}
6065

61-
function evalOperatorToken (operators: Operators, token: OperatorToken, lhs: any, rhs: any, ctx: Context) {
62-
const impl = operators[token.operator]
63-
return impl(lhs, rhs, ctx)
64-
}
65-
6666
function evalLiteralToken (token: LiteralToken) {
6767
return literalValues[token.literal]
6868
}

src/render/operator.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { isComparable } from '../drop/comparable'
22
import { Context } from '../context'
33
import { isFunction, toValue } from '../util'
4-
import { isTruthy } from '../render/boolean'
4+
import { isFalsy, isTruthy } from '../render/boolean'
55

6-
export type OperatorHandler = (lhs: any, rhs: any, ctx: Context) => boolean;
6+
export type UnaryOperatorHandler = (operand: any, ctx: Context) => boolean;
7+
export type BinaryOperatorHandler = (lhs: any, rhs: any, ctx: Context) => boolean;
8+
export type OperatorHandler = UnaryOperatorHandler | BinaryOperatorHandler;
79
export type Operators = Record<string, OperatorHandler>
810

911
export const defaultOperators: Operators = {
@@ -42,6 +44,7 @@ export const defaultOperators: Operators = {
4244
r = toValue(r)
4345
return l && isFunction(l.indexOf) ? l.indexOf(r) > -1 : false
4446
},
47+
'not': (v: any, ctx: Context) => isFalsy(toValue(v), ctx),
4548
'and': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) && isTruthy(toValue(r), ctx),
4649
'or': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) || isTruthy(toValue(r), ctx)
4750
}

src/tokens/operator-token.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
import { Token } from './token'
22
import { TokenKind } from '../parser'
33

4-
export const precedence = {
5-
'==': 1,
6-
'!=': 1,
7-
'>': 1,
8-
'<': 1,
9-
'>=': 1,
10-
'<=': 1,
11-
'contains': 1,
4+
export const enum OperatorType {
5+
Binary,
6+
Unary
7+
}
8+
9+
export const operatorPrecedences = {
10+
'==': 2,
11+
'!=': 2,
12+
'>': 2,
13+
'<': 2,
14+
'>=': 2,
15+
'<=': 2,
16+
'contains': 2,
17+
'not': 1,
1218
'and': 0,
1319
'or': 0
1420
}
1521

22+
export const operatorTypes = {
23+
'==': OperatorType.Binary,
24+
'!=': OperatorType.Binary,
25+
'>': OperatorType.Binary,
26+
'<': OperatorType.Binary,
27+
'>=': OperatorType.Binary,
28+
'<=': OperatorType.Binary,
29+
'contains': OperatorType.Binary,
30+
'not': OperatorType.Unary,
31+
'and': OperatorType.Binary,
32+
'or': OperatorType.Binary
33+
}
34+
1635
export class OperatorToken extends Token {
1736
public operator: string
1837
public constructor (
@@ -26,6 +45,6 @@ export class OperatorToken extends Token {
2645
}
2746
getPrecedence () {
2847
const key = this.getText()
29-
return key in precedence ? precedence[key] : 1
48+
return key in operatorPrecedences ? operatorPrecedences[key] : 1
3049
}
3150
}

test/e2e/issues.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,16 @@ describe('Issues', function () {
377377
const html = await liquid.parseAndRender(tpl)
378378
expect(html).to.match(/\w+, January \d+, 2023 at \d+:\d\d [ap]m [-+]\d\d\d\d/)
379379
})
380+
it('#575 Add support for Not operator', async () => {
381+
const liquid = new Liquid()
382+
const tpl = `
383+
{% if link and not button %}
384+
<a href="{{ link }}">Lot more code here</a>
385+
{% else %}
386+
<div>Lot more code here</div>
387+
{% endif %}`
388+
const ctx = { link: 'https://example.com', button: false }
389+
const html = await liquid.parseAndRender(tpl, ctx)
390+
expect(html.trim()).to.equal('<a href="https://example.com">Lot more code here</a>')
391+
})
380392
})

test/unit/parser/tokenizer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,11 @@ describe('Tokenizer', function () {
397397
it('should read expression `a ==`', () => {
398398
const exp = [...new Tokenizer('a ==').readExpressionTokens()]
399399

400-
expect(exp).to.have.lengthOf(1)
400+
expect(exp).to.have.lengthOf(2)
401401
expect(exp[0]).to.be.instanceOf(PropertyAccessToken)
402402
expect(exp[0].getText()).to.deep.equal('a')
403+
expect(exp[1]).to.be.instanceOf(OperatorToken)
404+
expect(exp[1].getText()).to.deep.equal('==')
403405
})
404406
it('should read expression `a==b`', () => {
405407
const exp = new Tokenizer('a==b').readExpressionTokens()

test/unit/render/expression.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ describe('Expression', function () {
154154
const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } })
155155
expect(await toPromise(create('obj[keys["what\'s this"]]').evaluate(ctx, false))).to.equal('FOO')
156156
})
157+
it('should support not', async function () {
158+
expect(await toPromise(create('not 1 < 2').evaluate(ctx))).to.equal(false)
159+
})
160+
it('not should have higher precedence than and/or', async function () {
161+
expect(await toPromise(create('not 1 < 2 or not 1 > 2').evaluate(ctx))).to.equal(true)
162+
expect(await toPromise(create('not 1 < 2 and not 1 > 2').evaluate(ctx))).to.equal(false)
163+
})
157164
})
158165

159166
describe('sync', function () {

0 commit comments

Comments
 (0)