From 76019e9e187eb860374053fd598dc508908c52fb Mon Sep 17 00:00:00 2001 From: harttle Date: Sun, 25 Aug 2019 23:49:03 +0800 Subject: [PATCH] refactor: rewrite expression evaluation, fix #130 --- src/builtin/filters/array.ts | 2 +- src/builtin/filters/object.ts | 2 +- src/builtin/tags/assign.ts | 4 +- src/builtin/tags/case.ts | 7 +- src/builtin/tags/cycle.ts | 8 +- src/builtin/tags/for.ts | 4 +- src/builtin/tags/if.ts | 4 +- src/builtin/tags/include.ts | 7 +- src/builtin/tags/layout.ts | 5 +- src/builtin/tags/tablerow.ts | 4 +- src/builtin/tags/unless.ts | 4 +- src/parser/literal.ts | 18 +++++ src/render/boolean.ts | 6 ++ src/render/expression.ts | 82 +++++++++++++++++++ src/render/operator.ts | 59 ++++++++++++++ src/render/range.ts | 17 ++++ src/render/syntax.ts | 100 ----------------------- src/render/value.ts | 23 ++++++ src/template/filter/filter.ts | 6 +- src/template/tag/hash.ts | 4 +- src/template/value.ts | 4 +- src/types.ts | 3 +- test/e2e/eval-value.ts | 2 +- test/integration/builtin/tags/if.ts | 11 ++- test/integration/builtin/tags/unless.ts | 8 +- test/unit/parser/literal.ts | 27 +++++++ test/unit/render/boolean.ts | 38 +++++++++ test/unit/render/expression.ts | 67 ++++++++++++++++ test/unit/render/syntax.ts | 102 ------------------------ test/unit/render/value.ts | 24 ++++++ tsconfig.json | 1 + 31 files changed, 407 insertions(+), 246 deletions(-) create mode 100644 src/parser/literal.ts create mode 100644 src/render/boolean.ts create mode 100644 src/render/expression.ts create mode 100644 src/render/operator.ts create mode 100644 src/render/range.ts delete mode 100644 src/render/syntax.ts create mode 100644 src/render/value.ts create mode 100644 test/unit/parser/literal.ts create mode 100644 test/unit/render/boolean.ts create mode 100644 test/unit/render/expression.ts delete mode 100644 test/unit/render/syntax.ts create mode 100644 test/unit/render/value.ts diff --git a/src/builtin/filters/array.ts b/src/builtin/filters/array.ts index 24bba707f..a56da41df 100644 --- a/src/builtin/filters/array.ts +++ b/src/builtin/filters/array.ts @@ -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), diff --git a/src/builtin/filters/object.ts b/src/builtin/filters/object.ts index 34a462781..6631f30d6 100644 --- a/src/builtin/filters/object.ts +++ b/src/builtin/filters/object.ts @@ -1,4 +1,4 @@ -import { isFalsy } from '../../render/syntax' +import { isFalsy } from '../../render/boolean' import { toValue } from '../../util/underscore' export default { diff --git a/src/builtin/tags/assign.ts b/src/builtin/tags/assign.ts index 202d46b0e..6c40b4f40 100644 --- a/src/builtin/tags/assign.ts +++ b/src/builtin/tags/assign.ts @@ -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*=([^]*)`) diff --git a/src/builtin/tags/case.ts b/src/builtin/tags/case.ts index 444b75eed..22cf9ecd2 100644 --- a/src/builtin/tags/case.ts +++ b/src/builtin/tags/case.ts @@ -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 diff --git a/src/builtin/tags/cycle.ts b/src/builtin/tags/cycle.ts index 419328a16..088f880cc 100644 --- a/src/builtin/tags/cycle.ts +++ b/src/builtin/tags/cycle.ts @@ -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 diff --git a/src/builtin/tags/for.ts b/src/builtin/tags/for.ts index 95f16355c..33728c381 100644 --- a/src/builtin/tags/for.ts +++ b/src/builtin/tags/for.ts @@ -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) { diff --git a/src/builtin/tags/if.ts b/src/builtin/tags/if.ts index aea40522d..ab643b67a 100644 --- a/src/builtin/tags/if.ts +++ b/src/builtin/tags/if.ts @@ -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 diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts index f94c2ae55..7c5e61455 100644 --- a/src/builtin/tags/include.ts +++ b/src/builtin/tags/include.ts @@ -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) diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts index 76892daf0..120ee450c 100644 --- a/src/builtin/tags/layout.ts +++ b/src/builtin/tags/layout.ts @@ -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`) diff --git a/src/builtin/tags/tablerow.ts b/src/builtin/tags/tablerow.ts index e09868a81..4648ba290 100644 --- a/src/builtin/tags/tablerow.ts +++ b/src/builtin/tags/tablerow.ts @@ -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 diff --git a/src/builtin/tags/unless.ts b/src/builtin/tags/unless.ts index 30ce60963..d2418225f 100644 --- a/src/builtin/tags/unless.ts +++ b/src/builtin/tags/unless.ts @@ -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) diff --git a/src/parser/literal.ts b/src/parser/literal.ts new file mode 100644 index 000000000..3f95c34a7 --- /dev/null +++ b/src/parser/literal.ts @@ -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) +} diff --git a/src/render/boolean.ts b/src/render/boolean.ts new file mode 100644 index 000000000..e561252b7 --- /dev/null +++ b/src/render/boolean.ts @@ -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 +} diff --git a/src/render/expression.ts b/src/render/expression.ts new file mode 100644 index 000000000..9e3cd35c5 --- /dev/null +++ b/src/render/expression.ts @@ -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 { + 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 { + 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()! + } +} diff --git a/src/render/operator.ts b/src/render/operator.ts new file mode 100644 index 000000000..fdc28372d --- /dev/null +++ b/src/render/operator.ts @@ -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) +} diff --git a/src/render/range.ts b/src/render/range.ts new file mode 100644 index 000000000..7d962ab8f --- /dev/null +++ b/src/render/range.ts @@ -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) + } +} diff --git a/src/render/syntax.ts b/src/render/syntax.ts deleted file mode 100644 index 44ea7c330..000000000 --- a/src/render/syntax.ts +++ /dev/null @@ -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 -} diff --git a/src/render/value.ts b/src/render/value.ts new file mode 100644 index 000000000..cbc3ad656 --- /dev/null +++ b/src/render/value.ts @@ -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)) + } +} diff --git a/src/template/filter/filter.ts b/src/template/filter/filter.ts index 4a77d29b4..818a995e8 100644 --- a/src/template/filter/filter.ts +++ b/src/template/filter/filter.ts @@ -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]) } diff --git a/src/template/tag/hash.ts b/src/template/tag/hash.ts index 7176377ba..07cd5f342 100644 --- a/src/template/tag/hash.ts +++ b/src/template/tag/hash.ts @@ -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 } diff --git a/src/template/value.ts b/src/template/value.ts index 2d97cf9e2..ca54f4f32 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -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) } diff --git a/src/types.ts b/src/types.ts index c0a19a57a..8a6ba69a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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' diff --git a/test/e2e/eval-value.ts b/test/e2e/eval-value.ts index a7bf78b1b..1b000a47b 100644 --- a/test/e2e/eval-value.ts +++ b/test/e2e/eval-value.ts @@ -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/) }) }) diff --git a/test/integration/builtin/tags/if.ts b/test/integration/builtin/tags/if.ts index 33eb16a14..8fb747d57 100644 --- a/test/integration/builtin/tags/if.ts +++ b/test/integration/builtin/tags/if.ts @@ -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 one2 %} 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('') }) diff --git a/test/unit/parser/literal.ts b/test/unit/parser/literal.ts new file mode 100644 index 000000000..265eabe14 --- /dev/null +++ b/test/unit/parser/literal.ts @@ -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) + }) +}) diff --git a/test/unit/render/boolean.ts b/test/unit/render/boolean.ts new file mode 100644 index 000000000..af4000c9f --- /dev/null +++ b/test/unit/render/boolean.ts @@ -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 + }) + }) +}) diff --git a/test/unit/render/expression.ts b/test/unit/render/expression.ts new file mode 100644 index 000000000..adbbf6dc0 --- /dev/null +++ b/test/unit/render/expression.ts @@ -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]) + }) +}) diff --git a/test/unit/render/syntax.ts b/test/unit/render/syntax.ts deleted file mode 100644 index 9daf25724..000000000 --- a/test/unit/render/syntax.ts +++ /dev/null @@ -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]) - }) - }) -}) diff --git a/test/unit/render/value.ts b/test/unit/render/value.ts new file mode 100644 index 000000000..96c9034b9 --- /dev/null +++ b/test/unit/render/value.ts @@ -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 + }) +}) diff --git a/tsconfig.json b/tsconfig.json index a313db1ff..9fa576dd3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "declaration": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, + "downlevelIteration": true, "strict": true, "suppressImplicitAnyIndexErrors": true },