refactor: rewrite expression evaluation, fix #130

This commit is contained in:
harttle
2019-08-26 10:15:43 -05:00
committed by Jun Yang
parent 538610e169
commit 76019e9e18
31 changed files with 407 additions and 246 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { isArray, last } from '../../util/underscore'
import { isTruthy } from '../../render/syntax'
import { isTruthy } from '../../render/boolean'
export default {
'join': (v: any[], arg: string) => v.join(arg === undefined ? ' ' : arg),
+1 -1
View File
@@ -1,4 +1,4 @@
import { isFalsy } from '../../render/syntax'
import { isFalsy } from '../../render/boolean'
import { toValue } from '../../util/underscore'
export default {
+1 -3
View File
@@ -1,8 +1,6 @@
import { assert } from '../../util/assert'
import { identifier } from '../../parser/lexical'
import { TagToken } from '../../parser/tag-token'
import { Context } from '../../context/context'
import { ITagImplOptions } from '../../template/tag/itag-impl-options'
import { ITagImplOptions, TagToken, Context } from '../../types'
const re = new RegExp(`(${identifier.source})\\s*=([^]*)`)
+3 -4
View File
@@ -1,5 +1,4 @@
import { Hash, Emitter, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
import { evalExp } from '../../render/syntax'
import { Expression, Hash, Emitter, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
@@ -28,8 +27,8 @@ export default {
render: async function (ctx: Context, hash: Hash, emitter: Emitter) {
for (let i = 0; i < this.cases.length; i++) {
const branch = this.cases[i]
const val = await evalExp(branch.val, ctx)
const cond = await evalExp(this.cond, ctx)
const val = new Expression(branch.val).value(ctx)
const cond = new Expression(this.cond).value(ctx)
if (val === cond) {
this.liquid.renderer.renderTemplates(branch.templates, ctx, emitter)
return
+4 -4
View File
@@ -1,6 +1,6 @@
import { assert } from '../../util/assert'
import { value as rValue } from '../../parser/lexical'
import { evalValue } from '../../render/syntax'
import { Expression } from '../../render/expression'
import { TagToken } from '../../parser/tag-token'
import { Context } from '../../context/context'
import { ITagImplOptions } from '../../template/tag/itag-impl-options'
@@ -13,7 +13,7 @@ export default {
let match: RegExpExecArray | null = groupRE.exec(tagToken.args) as RegExpExecArray
assert(match, `illegal tag: ${tagToken.raw}`)
this.group = match[1] || ''
this.group = new Expression(match[1])
const candidates = match[2]
this.candidates = []
@@ -25,7 +25,7 @@ export default {
},
render: async function (ctx: Context) {
const group = await evalValue(this.group, ctx)
const group = this.group.value(ctx)
const fingerprint = `cycle:${group}:` + this.candidates.join(',')
const groups = ctx.getRegister('cycle')
let idx = groups[fingerprint]
@@ -38,6 +38,6 @@ export default {
idx = (idx + 1) % this.candidates.length
groups[fingerprint] = idx
return evalValue(candidate, ctx)
return new Expression(candidate).value(ctx)
}
} as ITagImplOptions
+2 -2
View File
@@ -1,6 +1,6 @@
import { Emitter, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
import { isString, isObject, isArray } from '../../util/underscore'
import { parseExp } from '../../render/syntax'
import { Expression } from '../../render/expression'
import { assert } from '../../util/assert'
import { identifier, value, hash } from '../../parser/lexical'
import { ForloopDrop } from '../../drop/forloop-drop'
@@ -37,7 +37,7 @@ export default {
stream.start()
},
render: async function (ctx: Context, hash: Hash, emitter: Emitter) {
let collection = await parseExp(this.collection, ctx)
let collection = new Expression(this.collection).value(ctx)
if (!isArray(collection)) {
if (isString(collection) && collection.length > 0) {
+2 -2
View File
@@ -1,4 +1,4 @@
import { Hash, Emitter, evalExp, isTruthy, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
import { Hash, Emitter, isTruthy, Expression, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
@@ -29,7 +29,7 @@ export default {
render: async function (ctx: Context, hash: Hash, emitter: Emitter) {
for (const branch of this.branches) {
const cond = await evalExp(branch.cond, ctx)
const cond = new Expression(branch.cond).value(ctx)
if (isTruthy(cond)) {
await this.liquid.renderer.renderTemplates(branch.templates, ctx, emitter)
return
+3 -4
View File
@@ -1,7 +1,6 @@
import { assert } from '../../util/assert'
import { Hash, Emitter, TagToken, Context, ITagImplOptions } from '../../types'
import { Expression, Hash, Emitter, TagToken, Context, ITagImplOptions } from '../../types'
import { value, quotedLine } from '../../parser/lexical'
import { evalValue, parseValue } from '../../render/syntax'
import BlockMode from '../../context/block-mode'
const staticFileRE = /[^\s,]+/
@@ -25,7 +24,7 @@ export default {
const template = this.value.slice(1, -1)
filepath = await this.liquid.parseAndRender(template, ctx.getAll(), ctx.opts)
} else {
filepath = await evalValue(this.value, ctx)
filepath = new Expression(this.value).value(ctx)
}
} else {
filepath = this.staticValue
@@ -38,7 +37,7 @@ export default {
ctx.setRegister('blocks', {})
ctx.setRegister('blockMode', BlockMode.OUTPUT)
if (this.with) {
hash[filepath] = await parseValue(this.with, ctx)
hash[filepath] = new Expression(this.with).evaluate(ctx)
}
const templates = await this.liquid.getTemplate(filepath, ctx.opts)
ctx.push(hash)
+2 -3
View File
@@ -1,7 +1,6 @@
import { assert } from '../../util/assert'
import { value as rValue } from '../../parser/lexical'
import { evalValue } from '../../render/syntax'
import { TagToken, Token, Context, ITagImplOptions } from '../../types'
import { Expression, TagToken, Token, Context, ITagImplOptions } from '../../types'
import BlockMode from '../../context/block-mode'
import { Hash } from '../../template/tag/hash'
@@ -23,7 +22,7 @@ export default {
},
render: async function (ctx: Context, hash: Hash) {
const layout = ctx.opts.dynamicPartials
? await evalValue(this.layout, ctx)
? await (new Expression(this.layout).value(ctx))
: this.staticLayout
assert(layout, `cannot apply layout with empty filename`)
+2 -2
View File
@@ -1,5 +1,5 @@
import { assert } from '../../util/assert'
import { evalExp, Emitter, Hash, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
import { Expression, Emitter, Hash, TagToken, Token, Context, ITemplate, ITagImplOptions, ParseStream } from '../../types'
import { identifier, value, hash } from '../../parser/lexical'
import { TablerowloopDrop } from '../../drop/tablerowloop-drop'
@@ -29,7 +29,7 @@ export default {
},
render: async function (ctx: Context, hash: Hash, emitter: Emitter) {
let collection = await evalExp(this.collection, ctx) || []
let collection = new Expression(this.collection).value(ctx) || []
const offset = hash.offset || 0
const limit = (hash.limit === undefined) ? collection.length : hash.limit
+2 -2
View File
@@ -1,4 +1,4 @@
import { Emitter, evalExp, isFalsy, ParseStream, Context, ITagImplOptions, Token, Hash, TagToken } from '../../types'
import { Emitter, Expression, isFalsy, ParseStream, Context, ITagImplOptions, Token, Hash, TagToken } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: Token[]) {
@@ -21,7 +21,7 @@ export default {
},
render: async function (ctx: Context, hash: Hash, emitter: Emitter) {
const cond = await evalExp(this.cond, ctx)
const cond = new Expression(this.cond).value(ctx)
isFalsy(cond)
? await this.liquid.renderer.renderTemplates(this.templates, ctx, emitter)
: await this.liquid.renderer.renderTemplates(this.elseTemplates, ctx, emitter)
+18
View File
@@ -0,0 +1,18 @@
import { last } from '../util/underscore'
import { NullDrop } from '../drop/null-drop'
import { EmptyDrop } from '../drop/empty-drop'
import { BlankDrop } from '../drop/blank-drop'
type literal = true | false | NullDrop | EmptyDrop | BlankDrop | number | string
export function parseLiteral (str: string): literal | undefined {
str = str.trim()
if (str === 'true') return true
if (str === 'false') return false
if (str === 'nil' || str === 'null') return new NullDrop()
if (str === 'empty') return new EmptyDrop()
if (str === 'blank') return new BlankDrop()
if (!isNaN(Number(str))) return Number(str)
if ((str[0] === '"' || str[0] === "'") && str[0] === last(str)) return str.slice(1, -1)
}
+6
View File
@@ -0,0 +1,6 @@
export function isTruthy (val: any): boolean {
return !isFalsy(val)
}
export function isFalsy (val: any): boolean {
return val === false || undefined === val || val === null
}
+82
View File
@@ -0,0 +1,82 @@
import { assert } from '../util/assert'
import { isRange, rangeValue } from './range'
import { Value } from './value'
import { Context } from '../context/context'
import { toValue } from '../util/underscore'
import { isOperator, precedence, operatorImpls } from './operator'
export class Expression {
private str: string
public constructor (str: string = '') {
this.str = str
}
public evaluate (ctx: Context): any {
assert(ctx, 'unable to evaluate: context not defined')
const operands = []
for (const token of toPostfix(this.str)) {
if (isOperator(token)) {
const r = operands.pop()
const l = operands.pop()
const result = operatorImpls[token](l, r)
operands.push(result)
continue
}
if (isRange(token)) {
operands.push(rangeValue(token, ctx))
continue
}
operands.push(new Value(token).evaluate(ctx))
}
return operands[0]
}
public value (ctx: Context): any {
return toValue(this.evaluate(ctx))
}
}
function * tokenize (expr: string): IterableIterator<string> {
const N = expr.length
let str = ''
const pairs = { '"': '"', "'": "'", '[': ']', '(': ')' }
for (let i = 0; i < N; i++) {
const c = expr[i]
switch (c) {
case '[':
case '"':
case "'":
str += c
while (i + 1 < N) {
str += expr[++i]
if (expr[i] === pairs[c]) break
}
break
case ' ':
case '\t':
case '\n':
if (str) yield str
str = ''
break
default:
str += c
}
}
if (str) yield str
}
function * toPostfix (expr: string): IterableIterator<string> {
const ops = []
for (const token of tokenize(expr)) {
if (isOperator(token)) {
while (ops.length && precedence[ops[ops.length - 1]] > precedence[token]) {
yield ops.pop()!
}
ops.push(token)
} else yield token
}
while (ops.length) {
yield ops.pop()!
}
}
+59
View File
@@ -0,0 +1,59 @@
import { isComparable } from '../drop/icomparable'
import { isFunction } from '../util/underscore'
import { isTruthy } from '../render/boolean'
export const precedence = {
'==': 1,
'!=': 1,
'>': 1,
'<': 1,
'>=': 1,
'<=': 1,
'contains': 1,
'and': 0,
'or': 0
}
export const operatorImpls: {[key: string]: (lhs: any, rhs: any) => boolean} = {
'==': (l: any, r: any) => {
if (isComparable(l)) return l.equals(r)
if (isComparable(r)) return r.equals(l)
return l === r
},
'!=': (l: any, r: any) => {
if (isComparable(l)) return !l.equals(r)
if (isComparable(r)) return !r.equals(l)
return l !== r
},
'>': (l: any, r: any) => {
if (isComparable(l)) return l.gt(r)
if (isComparable(r)) return r.lt(l)
return l > r
},
'<': (l: any, r: any) => {
if (isComparable(l)) return l.lt(r)
if (isComparable(r)) return r.gt(l)
return l < r
},
'>=': (l: any, r: any) => {
if (isComparable(l)) return l.geq(r)
if (isComparable(r)) return r.leq(l)
return l >= r
},
'<=': (l: any, r: any) => {
if (isComparable(l)) return l.leq(r)
if (isComparable(r)) return r.geq(l)
return l <= r
},
'contains': (l: any, r: any) => {
return l && isFunction(l.indexOf) ? l.indexOf(r) > -1 : false
},
'and': (l: any, r: any) => isTruthy(l) && isTruthy(r),
'or': (l: any, r: any) => isTruthy(l) || isTruthy(r)
}
const list = Object.keys(precedence)
export function isOperator (token: string) {
return list.includes(token)
}
+17
View File
@@ -0,0 +1,17 @@
import { rangeLine } from '../parser/lexical'
import { Context } from '../context/context'
import { range } from '../util/underscore'
import { Value } from './value'
export function isRange (token: string = '') {
return token[0] === '(' && token[token.length - 1] === ')'
}
export function rangeValue (token: string = '', ctx: Context) {
let match
if ((match = token.match(rangeLine))) {
const low = new Value(match[1]).value(ctx)
const high = new Value(match[2]).value(ctx)
return range(+low, +high + 1)
}
}
-100
View File
@@ -1,100 +0,0 @@
import * as lexical from '../parser/lexical'
import { assert } from '../util/assert'
import { Context } from '../context/context'
import { range, last, isFunction, toValue } from '../util/underscore'
import { isComparable } from '../drop/icomparable'
import { NullDrop } from '../drop/null-drop'
import { EmptyDrop } from '../drop/empty-drop'
import { BlankDrop } from '../drop/blank-drop'
const binaryOperators: {[key: string]: (lhs: any, rhs: any) => boolean} = {
'==': (l: any, r: any) => {
if (isComparable(l)) return l.equals(r)
if (isComparable(r)) return r.equals(l)
return l === r
},
'!=': (l: any, r: any) => {
if (isComparable(l)) return !l.equals(r)
if (isComparable(r)) return !r.equals(l)
return l !== r
},
'>': (l: any, r: any) => {
if (isComparable(l)) return l.gt(r)
if (isComparable(r)) return r.lt(l)
return l > r
},
'<': (l: any, r: any) => {
if (isComparable(l)) return l.lt(r)
if (isComparable(r)) return r.gt(l)
return l < r
},
'>=': (l: any, r: any) => {
if (isComparable(l)) return l.geq(r)
if (isComparable(r)) return r.leq(l)
return l >= r
},
'<=': (l: any, r: any) => {
if (isComparable(l)) return l.leq(r)
if (isComparable(r)) return r.geq(l)
return l <= r
},
'contains': (l: any, r: any) => {
return l && isFunction(l.indexOf) ? l.indexOf(r) > -1 : false
},
'and': (l: any, r: any) => isTruthy(l) && isTruthy(r),
'or': (l: any, r: any) => isTruthy(l) || isTruthy(r)
}
export function parseExp (exp: string, ctx: Context): any {
assert(ctx, 'unable to parseExp: scope undefined')
const operatorREs = lexical.operators
let match
for (let i = 0; i < operatorREs.length; i++) {
const operatorRE = operatorREs[i]
const expRE = new RegExp(`^(${lexical.quoteBalanced.source})(${operatorRE.source})(${lexical.quoteBalanced.source})$`)
if ((match = exp.match(expRE))) {
const l = parseExp(match[1], ctx)
const op = binaryOperators[match[2].trim()]
const r = parseExp(match[3], ctx)
return op(l, r)
}
}
if ((match = exp.match(lexical.rangeLine))) {
const low = evalValue(match[1], ctx)
const high = evalValue(match[2], ctx)
return range(+low, +high + 1)
}
return parseValue(exp, ctx)
}
export function evalExp (str: string, ctx: Context): any {
return toValue(parseExp(str, ctx))
}
export function parseValue (str: string | undefined, ctx: Context): any {
if (!str) return null
str = str.trim()
if (str === 'true') return true
if (str === 'false') return false
if (str === 'nil' || str === 'null') return new NullDrop()
if (str === 'empty') return new EmptyDrop()
if (str === 'blank') return new BlankDrop()
if (!isNaN(Number(str))) return Number(str)
if ((str[0] === '"' || str[0] === "'") && str[0] === last(str)) return str.slice(1, -1)
return ctx.get(str)
}
export function evalValue (str: string | undefined, ctx: Context) {
return toValue(parseValue(str, ctx))
}
export function isTruthy (val: any): boolean {
return !isFalsy(val)
}
export function isFalsy (val: any): boolean {
return val === false || undefined === val || val === null
}
+23
View File
@@ -0,0 +1,23 @@
import { toValue } from '../util/underscore'
import { Context } from '../context/context'
import { parseLiteral } from '../parser/literal'
export class Value {
private str: string
public constructor (str: string = '') {
this.str = str
}
public evaluate (ctx: Context) {
const literalValue = parseLiteral(this.str)
if (literalValue !== undefined) {
return literalValue
}
return ctx.get(this.str)
}
public value (ctx: Context) {
return toValue(this.evaluate(ctx))
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { parseValue } from '../../render/syntax'
import { Expression } from '../../render/expression'
import { Context } from '../../context/context'
import { isArray } from '../../util/underscore'
import { FilterImplOptions } from './filter-impl-options'
@@ -24,8 +24,8 @@ export class Filter {
public render (value: any, context: Context) {
const argv: any[] = []
for (const arg of this.args) {
if (isKeyValuePair(arg)) argv.push([arg[0], parseValue(arg[1], context)])
else argv.push(parseValue(arg, context))
if (isKeyValuePair(arg)) argv.push([arg[0], new Expression(arg[1]).evaluate(context)])
else argv.push(new Expression(arg).evaluate(context))
}
return this.impl.apply({ context }, [value, ...argv])
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { hashCapture } from '../../parser/lexical'
import { parseValue } from '../../render/syntax'
import { Expression } from '../../render/expression'
import { Context } from '../../context/context'
/**
@@ -17,7 +17,7 @@ export class Hash {
while ((match = hashCapture.exec(markup))) {
const k = match[1]
const v = match[2]
instance[k] = await parseValue(v, ctx)
instance[k] = new Expression(v).evaluate(ctx)
}
return instance
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { parseExp } from '../render/syntax'
import { Expression } from '../render/expression'
import { FilterArgs, Filter } from './filter/filter'
import { Context } from '../context/context'
@@ -48,7 +48,7 @@ export class Value {
this.filters.push(new Filter(name, args, this.strictFilters))
}
public value (ctx: Context) {
let val = parseExp(this.initial, ctx)
let val = new Expression(this.initial).evaluate(ctx)
for (const filter of this.filters) {
val = filter.render(val, ctx)
}
+2 -1
View File
@@ -1,7 +1,8 @@
export { ParseError, TokenizationError, RenderBreakError, AssertionError } from './util/error'
export { Drop } from './drop/drop'
export { Emitter } from './render/emitter'
export { isTruthy, isFalsy, evalExp, evalValue } from './render/syntax'
export { Expression } from './render/expression'
export { isFalsy, isTruthy } from './render/boolean'
export { TagToken } from './parser/tag-token'
export { Context } from './context/context'
export { ITemplate } from './template/itemplate'
+1 -1
View File
@@ -6,6 +6,6 @@ describe('.evalValue()', function () {
beforeEach(() => { engine = new Liquid() })
it('should throw when scope undefined', async function () {
return expect(() => engine.evalValue('{{"foo"}}', null as any)).to.throw(/scope undefined/)
return expect(() => engine.evalValue('{{"foo"}}', null as any)).to.throw(/context not defined/)
})
})
+8 -3
View File
@@ -40,17 +40,17 @@ describe('tags/if', function () {
})
describe('expression as condition', function () {
it('should support ==', async function () {
const src = '{% if 2==3 %}yes{%else%}no{%endif%}'
const src = '{% if 2 == 3 %}yes{%else%}no{%endif%}'
const html = await liquid.parseAndRender(src, ctx)
return expect(html).to.equal('no')
})
it('should support >=', async function () {
const src = '{% if 1>=2 and one<two %}a{%endif%}'
const src = '{% if 1 >= 2 and one<two %}a{%endif%}'
const html = await liquid.parseAndRender(src, ctx)
return expect(html).to.equal('')
})
it('should support !=', async function () {
const src = '{% if one!=two %}yes{%else%}no{%endif%}'
const src = '{% if one != two %}yes{%else%}no{%endif%}'
const html = await liquid.parseAndRender(src, ctx)
return expect(html).to.equal('yes')
})
@@ -60,6 +60,11 @@ describe('tags/if', function () {
const html = await liquid.parseAndRender(src, ctx)
return expect(html).to.equal('XY')
})
it('should evaluate right to left', async function () {
const src = `{% if false and false or true %}true{%endif%}`
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('')
})
})
describe('comparasion to null', function () {
it('should evaluate false for null < 10', async function () {
+4 -4
View File
@@ -20,17 +20,17 @@ describe('tags/unless', function () {
return expect(html).to.equal('yes')
})
it('should reject when tag not closed', function () {
const src = '{% unless 1>2 %}yes'
const src = '{% unless 1 > 2 %}yes'
return expect(liquid.parseAndRender(src))
.to.be.rejectedWith(/tag {% unless 1>2 %} not closed/)
.to.be.rejectedWith(/tag {% unless 1 > 2 %} not closed/)
})
it('should render unless when predicate yields false and else undefined', async function () {
const src = '{% unless 1>2 %}yes{%endunless%}'
const src = '{% unless 1 > 2 %}yes{%endunless%}'
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('yes')
})
it('should render "" when predicate yields false and else undefined', async function () {
const src = '{% unless 1<2 %}yes{%endunless%}'
const src = '{% unless 1 < 2 %}yes{%endunless%}'
const html = await liquid.parseAndRender(src)
return expect(html).to.equal('')
})
+27
View File
@@ -0,0 +1,27 @@
import { expect } from 'chai'
import { parseLiteral } from '../../../src/parser/literal'
import { NullDrop } from '../../../src/drop/null-drop'
describe('parseLiteral', function () {
it('should eval boolean literal', async function () {
expect(parseLiteral('true')).to.equal(true)
expect(parseLiteral('TrUE')).to.equal(undefined)
expect(parseLiteral('false')).to.equal(false)
})
it('should eval number literal', async function () {
expect(parseLiteral('2.3')).to.equal(2.3)
expect(parseLiteral('.32')).to.equal(0.32)
expect(parseLiteral('-23.')).to.equal(-23)
expect(parseLiteral('23')).to.equal(23)
})
it('should eval string literal', async function () {
expect(parseLiteral('"ab\'c"')).to.equal("ab'c")
expect(parseLiteral("'ab\"c'")).to.equal('ab"c')
})
it('should eval nil literal', async function () {
expect(parseLiteral('nil')).to.be.instanceOf(NullDrop)
})
it('should eval null literal', async function () {
expect(parseLiteral('null')).to.be.instanceOf(NullDrop)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { isTruthy } from '../../../src/render/boolean'
import { expect } from 'chai'
describe('boolean', async function () {
describe('.isTruthy()', async function () {
// Spec: https://shopify.github.io/liquid/basics/truthy-and-falsy/
it('true is truthy', function () {
expect(isTruthy(true)).to.be.true
})
it('false is falsy', function () {
expect(isTruthy(false)).to.be.false
})
it('null is falsy', function () {
expect(isTruthy(null)).to.be.false
})
it('"foo" is truthy', function () {
expect(isTruthy('foo')).to.be.true
})
it('"" is truthy', function () {
expect(isTruthy('')).to.be.true
})
it('0 is truthy', function () {
expect(isTruthy(0)).to.be.true
})
it('1 is truthy', function () {
expect(isTruthy(1)).to.be.true
})
it('1.1 is truthy', function () {
expect(isTruthy(1.1)).to.be.true
})
it('[1] is truthy', function () {
expect(isTruthy([1])).to.be.true
})
it('[] is truthy', function () {
expect(isTruthy([])).to.be.true
})
})
})
+67
View File
@@ -0,0 +1,67 @@
import { Expression } from '../../../src/render/expression'
import { expect } from 'chai'
import { Context } from '../../../src/context/context'
describe('Expression', function () {
let ctx: Context
beforeEach(function () {
ctx = new Context({
one: 1,
two: 2,
empty: '',
x: 'XXX',
y: undefined,
z: null
})
})
it('should throw when context not defined', async function () {
return expect(() => new Expression().value()).to.throw(/context not defined/)
})
it('should eval simple expression', async function () {
expect(new Expression('1 < 2').value(ctx)).to.equal(true)
expect(new Expression('2 <= 2').value(ctx)).to.equal(true)
expect(new Expression('one <= two').value(ctx)).to.equal(true)
expect(new Expression('x contains "x"').value(ctx)).to.equal(false)
expect(new Expression('x contains "X"').value(ctx)).to.equal(true)
expect(new Expression('1 contains "x"').value(ctx)).to.equal(false)
expect(new Expression('y contains "x"').value(ctx)).to.equal(false)
expect(new Expression('z contains "x"').value(ctx)).to.equal(false)
expect(new Expression('(1..5) contains 3').value(ctx)).to.equal(true)
expect(new Expression('(1..5) contains 6').value(ctx)).to.equal(false)
expect(new Expression('"<=" == "<="').value(ctx)).to.equal(true)
})
describe('complex expression', function () {
it('should support value or value', async function () {
expect(new Expression('false or true').value(ctx)).to.equal(true)
})
it('should support < and contains', async function () {
expect(new Expression('1 < 2 and x contains "x"').value(ctx)).to.equal(false)
})
it('should support < or contains', async function () {
expect(new Expression('1 < 2 or x contains "x"').value(ctx)).to.equal(true)
})
it('should support value and !=', async function () {
expect(new Expression('empty and empty != ""').value(ctx)).to.equal(false)
})
it('should recognize quoted value', async function () {
expect(new Expression('">"').value(ctx)).to.equal('>')
})
it('should evaluate from right to left', function () {
expect(new Expression('true or false and false').value(ctx)).to.equal(true)
expect(new Expression('true and false and false or true').value(ctx)).to.equal(false)
})
it('should recognize property access', function () {
const ctx = new Context({ obj: { foo: true } })
expect(new Expression('obj["foo"] and true').value(ctx)).to.equal(true)
})
})
it('should eval range expression', async function () {
expect(new Expression('(2..4)').value(ctx)).to.deep.equal([2, 3, 4])
expect(new Expression('(two..4)').value(ctx)).to.deep.equal([2, 3, 4])
})
})
-102
View File
@@ -1,102 +0,0 @@
import { Context } from '../../../src/context/context'
import { expect } from 'chai'
import { evalExp, evalValue, isTruthy } from '../../../src/render/syntax'
describe('render/syntax', function () {
let ctx: Context
beforeEach(function () {
ctx = new Context({
one: 1,
two: 2,
empty: '',
x: 'XXX',
y: undefined,
z: null,
'has_value?': true
})
})
describe('.evalValue()', function () {
it('should eval boolean literal', async function () {
expect(await evalValue('true', ctx)).to.equal(true)
expect(await evalValue('TrUE', ctx)).to.equal(undefined)
expect(await evalValue('false', ctx)).to.equal(false)
})
it('should eval number literal', async function () {
expect(await evalValue('2.3', ctx)).to.equal(2.3)
expect(await evalValue('.32', ctx)).to.equal(0.32)
expect(await evalValue('-23.', ctx)).to.equal(-23)
expect(await evalValue('23', ctx)).to.equal(23)
})
it('should eval string literal', async function () {
expect(await evalValue('"ab\'c"', ctx)).to.equal("ab'c")
expect(await evalValue("'ab\"c'", ctx)).to.equal('ab"c')
})
it('should eval nil literal', async function () {
expect(await evalValue('nil', ctx)).to.be.null
})
it('should eval null literal', async function () {
expect(await evalValue('null', ctx)).to.be.null
})
it('should eval scope variables', async function () {
expect(await evalValue('one', ctx)).to.equal(1)
expect(await evalValue('has_value?', ctx)).to.equal(true)
expect(await evalValue('x', ctx)).to.equal('XXX')
})
})
describe('.isTruthy()', async function () {
// Spec: https://shopify.github.io/liquid/basics/truthy-and-falsy/
expect(isTruthy(true)).to.be.true
expect(isTruthy(false)).to.be.false
expect(isTruthy(null)).to.be.false
expect(isTruthy('foo')).to.be.true
expect(isTruthy('')).to.be.true
expect(isTruthy(0)).to.be.true
expect(isTruthy(1)).to.be.true
expect(isTruthy(1.1)).to.be.true
expect(isTruthy([1])).to.be.true
expect(isTruthy([])).to.be.true
})
describe('.evalExp()', function () {
it('should throw when scope undefined', async function () {
return expect(() => (evalExp as any)('')).to.throw(/scope undefined/)
})
it('should eval simple expression', async function () {
expect(await evalExp('1<2', ctx)).to.equal(true)
expect(await evalExp('2<=2', ctx)).to.equal(true)
expect(await evalExp('one<=two', ctx)).to.equal(true)
expect(await evalExp('x contains "x"', ctx)).to.equal(false)
expect(await evalExp('x contains "X"', ctx)).to.equal(true)
expect(await evalExp('1 contains "x"', ctx)).to.equal(false)
expect(await evalExp('y contains "x"', ctx)).to.equal(false)
expect(await evalExp('z contains "x"', ctx)).to.equal(false)
expect(await evalExp('(1..5) contains 3', ctx)).to.equal(true)
expect(await evalExp('(1..5) contains 6', ctx)).to.equal(false)
expect(await evalExp('"<=" == "<="', ctx)).to.equal(true)
})
describe('complex expression', function () {
it('should support value or value', async function () {
expect(await evalExp('false or true', ctx)).to.equal(true)
})
it('should support < and contains', async function () {
expect(await evalExp('1<2 and x contains "x"', ctx)).to.equal(false)
})
it('should support < or contains', async function () {
expect(await evalExp('1<2 or x contains "x"', ctx)).to.equal(true)
})
it('should support value and !=', async function () {
expect(await evalExp('empty and empty != ""', ctx)).to.equal(false)
})
})
it('should eval range expression', async function () {
expect(await evalExp('(2..4)', ctx)).to.deep.equal([2, 3, 4])
expect(await evalExp('(two..4)', ctx)).to.deep.equal([2, 3, 4])
})
})
})
+24
View File
@@ -0,0 +1,24 @@
import { Value } from '../../../src/render/value'
import { Context } from '../../../src/context/context'
import { expect } from 'chai'
describe('Value', function () {
it('should eval number variable', async function () {
const ctx = new Context({ one: 1 })
expect(new Value('one').value(ctx)).to.equal(1)
})
it('question mark should be valid variable name', async function () {
const ctx = new Context({ 'has_value?': true })
expect(new Value('has_value?').value(ctx)).to.equal(true)
})
it('should eval string variable', async function () {
const ctx = new Context({ x: 'XXX' })
expect(new Value('x').value(ctx)).to.equal('XXX')
})
it('should eval null literal', async function () {
expect(new Value('null').value({})).to.be.null
})
it('should eval nil literal', async function () {
expect(new Value('nil').value({})).to.be.null
})
})
+1
View File
@@ -8,6 +8,7 @@
"declaration": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"strict": true,
"suppressImplicitAnyIndexErrors": true
},