feat: add operators option for custom operators

This commit is contained in:
Jason Etcovitch
2021-02-04 09:11:28 +08:00
committed by Jun Yang
parent 5901611af5
commit 75591cdbfc
7 changed files with 65 additions and 53 deletions
+2 -2
View File
@@ -26,10 +26,10 @@ export default {
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
const cond = yield new Expression(this.cond).value(ctx)
const cond = yield new Expression(this.cond, this.liquid.options.operators).value(ctx)
for (let i = 0; i < this.cases.length; i++) {
const branch = this.cases[i]
const val = yield new Expression(branch.val).value(ctx)
const val = yield new Expression(branch.val, this.liquid.options.operators).value(ctx)
if (val === cond) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+1 -1
View File
@@ -31,7 +31,7 @@ export default {
const r = this.liquid.renderer
for (const branch of this.branches) {
const cond = yield new Expression(branch.cond, ctx.opts.lenientIf).value(ctx)
const cond = yield new Expression(branch.cond, this.liquid.options.operators, ctx.opts.lenientIf).value(ctx)
if (isTruthy(cond, ctx)) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+2 -2
View File
@@ -29,7 +29,7 @@ export default {
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
const cond = yield new Expression(this.cond, ctx.opts.lenientIf).value(ctx)
const cond = yield new Expression(this.cond, this.liquid.options.operators, ctx.opts.lenientIf).value(ctx)
if (isFalsy(cond, ctx)) {
yield r.renderTemplates(this.templates, ctx, emitter)
@@ -37,7 +37,7 @@ export default {
}
for (const branch of this.branches) {
const cond = yield new Expression(branch.cond, ctx.opts.lenientIf).value(ctx)
const cond = yield new Expression(branch.cond, this.liquid.options.operators, ctx.opts.lenientIf).value(ctx)
if (isTruthy(cond, ctx)) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+6 -1
View File
@@ -3,6 +3,7 @@ import { Template } from './template/template'
import { Cache } from './cache/cache'
import { LRU } from './cache/lru'
import { FS } from './fs/fs'
import { operatorImpls, OperatorMap } from './render/operator'
export interface LiquidOptions {
/** A directory or an array of directories from where to resolve layout and include templates, and the filename passed to `.renderFile()`. If it's an array, the files are looked up in the order they occur in the array. Defaults to `["."]` */
@@ -47,6 +48,8 @@ export interface LiquidOptions {
globals?: object;
/** Whether or not to keep value type when writing the Output. Defaults to `false`. */
keepOutputType?: boolean;
/** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
operators?: OperatorMap;
}
interface NormalizedOptions extends LiquidOptions {
@@ -75,6 +78,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
greedy: boolean;
globals: object;
keepOutputType: boolean;
operators: OperatorMap;
}
export const defaultOptions: NormalizedFullOptions = {
@@ -97,7 +101,8 @@ export const defaultOptions: NormalizedFullOptions = {
strictVariables: false,
lenientIf: false,
globals: {},
keepOutputType: false
keepOutputType: false,
operators: operatorImpls
}
export function normalize (options?: LiquidOptions): NormalizedOptions {
+7 -5
View File
@@ -11,25 +11,27 @@ import { parseStringLiteral } from '../parser/parse-string-literal'
import { Context } from '../context/context'
import { range, toValue } from '../util/underscore'
import { Tokenizer } from '../parser/tokenizer'
import { operatorImpls } from '../render/operator'
import { OperatorMap } from '../render/operator'
import { UndefinedVariableError, InternalUndefinedVariableError } from '../util/error'
export class Expression {
private operands: any[] = []
private postfix: Token[]
private lenient: boolean
private operators: OperatorMap
public constructor (str: string, lenient = false) {
public constructor (str: string, operators: OperatorMap, lenient = false) {
const tokenizer = new Tokenizer(str)
this.postfix = [...toPostfix(tokenizer.readExpression())]
this.lenient = lenient
this.operators = operators
}
public evaluate (ctx: Context): any {
for (const token of this.postfix) {
if (TypeGuards.isOperatorToken(token)) {
const r = this.operands.pop()
const l = this.operands.pop()
const result = evalOperatorToken(token, l, r, ctx)
const result = evalOperatorToken(this.operators, token, l, r, ctx)
this.operands.push(result)
} else {
this.operands.push(evalToken(token, ctx, this.lenient && this.postfix.length === 1))
@@ -73,8 +75,8 @@ export function evalQuotedToken (token: QuotedToken) {
return parseStringLiteral(token.getText())
}
function evalOperatorToken (token: OperatorToken, lhs: any, rhs: any, ctx: Context) {
const impl = operatorImpls[token.operator]
function evalOperatorToken (operators: OperatorMap, token: OperatorToken, lhs: any, rhs: any, ctx: Context) {
const impl = operators[token.operator]
return impl(lhs, rhs, ctx)
}
+5 -1
View File
@@ -3,7 +3,11 @@ import { Context } from '../context/context'
import { isFunction } from '../util/underscore'
import { isTruthy } from '../render/boolean'
export const operatorImpls: {[key: string]: (lhs: any, rhs: any, ctx: Context) => boolean} = {
export interface OperatorMap {
[key: string]: (lhs: any, rhs: any, ctx: Context) => boolean;
}
export const operatorImpls: OperatorMap = {
'==': (l: any, r: any) => {
if (isComparable(l)) return l.equals(r)
if (isComparable(r)) return r.equals(l)
+42 -41
View File
@@ -2,12 +2,13 @@ import { Expression } from '../../../src/render/expression'
import { expect } from 'chai'
import { Context } from '../../../src/context/context'
import { toThenable } from '../../../src/util/async'
import { operatorImpls } from '../../../src/render/operator'
describe('Expression', function () {
const ctx = new Context({})
it('should throw when context not defined', done => {
toThenable(new Expression('foo').value(undefined!))
toThenable(new Expression('foo', operatorImpls).value(undefined!))
.then(() => done(new Error('should not resolved')))
.catch(err => {
expect(err.message).to.match(/context not defined/)
@@ -17,19 +18,19 @@ describe('Expression', function () {
describe('single value', function () {
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4').value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"').value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('2.4', operatorImpls).value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"', operatorImpls).value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false', operatorImpls).value(ctx))).to.equal(false)
})
it('should eval range expression', async function () {
const ctx = new Context({ two: 2 })
expect(await toThenable(new Expression('(2..4)').value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(two..4)').value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(2..4)', operatorImpls).value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(two..4)', operatorImpls).value(ctx))).to.deep.equal([2, 3, 4])
})
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4').value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"').value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('2.4', operatorImpls).value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"', operatorImpls).value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false', operatorImpls).value(ctx))).to.equal(false)
})
it('should eval property access', async function () {
@@ -38,112 +39,112 @@ describe('Expression', function () {
coo: 'bar',
doo: { foo: 'bar', bar: { foo: 'bar' } }
})
expect(await toThenable(new Expression('foo.bar').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo["bar"]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[coo]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo.foo]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo["foo"]]').value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('doo[coo].foo').value(ctx))).to.equal('bar')
expect(await toThenable(new Expression('foo.bar', operatorImpls).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo["bar"]', operatorImpls).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[coo]', operatorImpls).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo.foo]', operatorImpls).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo["foo"]]', operatorImpls).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('doo[coo].foo', operatorImpls).value(ctx))).to.equal('bar')
})
})
describe('simple expression', function () {
it('should return false for "1==2"', async () => {
expect(await toThenable(new Expression('1==2').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('1==2', operatorImpls).value(ctx))).to.equal(false)
})
it('should return true for "1<2"', async () => {
expect(await toThenable(new Expression('1<2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1<2', operatorImpls).value(ctx))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 < 2', operatorImpls).value(ctx))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 < 2', operatorImpls).value(ctx))).to.equal(true)
})
it('should return true for "2 <= 2"', async () => {
expect(await toThenable(new Expression('2 <= 2').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('2 <= 2', operatorImpls).value(ctx))).to.equal(true)
})
it('should return true for "one <= two"', async () => {
const ctx = new Context({ one: 1, two: 2 })
expect(await toThenable(new Expression('one <= two').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('one <= two', operatorImpls).value(ctx))).to.equal(true)
})
it('should return false for "x contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('x contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('x contains "x"', operatorImpls).value(ctx))).to.equal(false)
})
it('should return true for "x contains "X""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('x contains "X"').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('x contains "X"', operatorImpls).value(ctx))).to.equal(true)
})
it('should return false for "1 contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('1 contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('1 contains "x"', operatorImpls).value(ctx))).to.equal(false)
})
it('should return false for "y contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('y contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('y contains "x"', operatorImpls).value(ctx))).to.equal(false)
})
it('should return false for "z contains "x""', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('z contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('z contains "x"', operatorImpls).value(ctx))).to.equal(false)
})
it('should return true for "(1..5) contains 3"', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('(1..5) contains 3').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('(1..5) contains 3', operatorImpls).value(ctx))).to.equal(true)
})
it('should return false for "(1..5) contains 6"', async () => {
const ctx = new Context({ x: 'XXX' })
expect(await toThenable(new Expression('(1..5) contains 6').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('(1..5) contains 6', operatorImpls).value(ctx))).to.equal(false)
})
it('should return true for ""<=" == "<=""', async () => {
expect(await toThenable(new Expression('"<=" == "<="').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('"<=" == "<="', operatorImpls).value(ctx))).to.equal(true)
})
})
it('should allow space in quoted value', async function () {
const ctx = new Context({ space: ' ' })
expect(await toThenable(new Expression('" " == space').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('" " == space', operatorImpls).value(ctx))).to.equal(true)
})
describe('escape', () => {
it('should escape quote', async function () {
const ctx = new Context({ quote: '"' })
expect(await toThenable(new Expression('"\\"" == quote').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('"\\"" == quote', operatorImpls).value(ctx))).to.equal(true)
})
it('should escape square bracket', async function () {
const ctx = new Context({ obj: { ']': 'bracket' } })
expect(await toThenable(new Expression('obj["]"] == "bracket"').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('obj["]"] == "bracket"', operatorImpls).value(ctx))).to.equal(true)
})
})
describe('complex expression', function () {
it('should support value or value', async function () {
expect(await toThenable(new Expression('false or true').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('false or true', operatorImpls).value(ctx))).to.equal(true)
})
it('should support < and contains', async function () {
expect(await toThenable(new Expression('1 < 2 and x contains "x"').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('1 < 2 and x contains "x"', operatorImpls).value(ctx))).to.equal(false)
})
it('should support < or contains', async function () {
expect(await toThenable(new Expression('1 < 2 or x contains "x"').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('1 < 2 or x contains "x"', operatorImpls).value(ctx))).to.equal(true)
})
it('should support value and !=', async function () {
const ctx = new Context({ empty: '' })
expect(await toThenable(new Expression('empty and empty != ""').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('empty and empty != ""', operatorImpls).value(ctx))).to.equal(false)
})
it('should recognize quoted value', async function () {
expect(await toThenable(new Expression('">"').value(ctx))).to.equal('>')
expect(await toThenable(new Expression('">"', operatorImpls).value(ctx))).to.equal('>')
})
it('should evaluate from right to left', async function () {
expect(await toThenable(new Expression('true or false and false').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('true and false and false or true').value(ctx))).to.equal(false)
expect(await toThenable(new Expression('true or false and false', operatorImpls).value(ctx))).to.equal(true)
expect(await toThenable(new Expression('true and false and false or true', operatorImpls).value(ctx))).to.equal(false)
})
it('should recognize property access', async function () {
const ctx = new Context({ obj: { foo: true } })
expect(await toThenable(new Expression('obj["foo"] and true').value(ctx))).to.equal(true)
expect(await toThenable(new Expression('obj["foo"] and true', operatorImpls).value(ctx))).to.equal(true)
})
it('should allow nested property access', async function () {
const ctx = new Context({ obj: { foo: 'FOO' }, keys: { "what's this": 'foo' } })
expect(await toThenable(new Expression('obj[keys["what\'s this"]]').value(ctx))).to.equal('FOO')
expect(await toThenable(new Expression('obj[keys["what\'s this"]]', operatorImpls).value(ctx))).to.equal('FOO')
})
})
})