Skip to content

Commit

Permalink
feat: support not operator, #575
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Jan 2, 2023
1 parent aafaa0b commit 3f21382
Show file tree
Hide file tree
Showing 7 changed files with 74 additions and 33 deletions.
20 changes: 9 additions & 11 deletions src/parser/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,18 @@ export class Tokenizer {
}

* readExpressionTokens (): IterableIterator<Token> {
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 {
Expand Down
20 changes: 10 additions & 10 deletions src/render/expression.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -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))
Expand Down Expand Up @@ -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]
}
Expand Down
7 changes: 5 additions & 2 deletions src/render/operator.ts
Original file line number Diff line number Diff line change
@@ -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<string, OperatorHandler>

export const defaultOperators: Operators = {
Expand Down Expand Up @@ -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)
}
37 changes: 28 additions & 9 deletions src/tokens/operator-token.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
}
}
12 changes: 12 additions & 0 deletions test/e2e/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<a href="{{ link }}">Lot more code here</a>
{% else %}
<div>Lot more code here</div>
{% endif %}`
const ctx = { link: 'https://example.com', button: false }
const html = await liquid.parseAndRender(tpl, ctx)
expect(html.trim()).to.equal('<a href="https://example.com">Lot more code here</a>')
})
})
4 changes: 3 additions & 1 deletion test/unit/parser/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions test/unit/render/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down

0 comments on commit 3f21382

Please sign in to comment.