feat: support filters in if/unless/case, see #287

This commit is contained in:
harttle
2021-02-12 22:22:41 +08:00
parent 4e82da689e
commit 2f059f6a25
18 changed files with 164 additions and 176 deletions
+6 -8
View File
@@ -1,8 +1,8 @@
import { Expression, Emitter, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { toValue, Value, Emitter, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
this.cond = tagToken.args
this.cond = new Value(tagToken.args, this.liquid)
this.cases = []
this.elseTemplates = []
@@ -10,7 +10,7 @@ export default {
const stream: ParseStream = this.liquid.parser.parseStream(remainTokens)
.on('tag:when', (token: TagToken) => {
this.cases.push({
val: token.args,
val: new Value(token.args, this.liquid),
templates: p = []
})
})
@@ -26,11 +26,9 @@ export default {
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
const { operators, operatorsTrie } = this.liquid.options
const cond = yield new Expression(this.cond, operators, operatorsTrie).value(ctx)
for (let i = 0; i < this.cases.length; i++) {
const branch = this.cases[i]
const val = yield new Expression(branch.val, operators, operatorsTrie).value(ctx)
const cond = toValue(yield this.cond.value(ctx, ctx.opts.lenientIf))
for (const branch of this.cases) {
const val = toValue(yield branch.val.value(ctx, ctx.opts.lenientIf))
if (val === cond) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+4 -5
View File
@@ -1,4 +1,4 @@
import { Emitter, isTruthy, Expression, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { Value, Emitter, isTruthy, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
@@ -8,12 +8,12 @@ export default {
let p
const stream: ParseStream = this.liquid.parser.parseStream(remainTokens)
.on('start', () => this.branches.push({
cond: tagToken.args,
cond: new Value(tagToken.args, this.liquid),
templates: (p = [])
}))
.on('tag:elsif', (token: TagToken) => {
this.branches.push({
cond: token.args,
cond: new Value(token.args, this.liquid),
templates: p = []
})
})
@@ -29,10 +29,9 @@ export default {
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
const { operators, operatorsTrie } = this.liquid.options
for (const branch of this.branches) {
const cond = yield new Expression(branch.cond, operators, operatorsTrie, ctx.opts.lenientIf).value(ctx)
const cond = yield branch.cond.value(ctx, ctx.opts.lenientIf)
if (isTruthy(cond, ctx)) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+5 -6
View File
@@ -1,4 +1,4 @@
import { TopLevelToken, Template, Emitter, Expression, isTruthy, isFalsy, ParseStream, Context, TagImplOptions, TagToken } from '../../types'
import { Value, TopLevelToken, Template, Emitter, isTruthy, isFalsy, ParseStream, Context, TagImplOptions, TagToken } from '../../types'
export default {
parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) {
@@ -9,11 +9,11 @@ export default {
const stream: ParseStream = this.liquid.parser.parseStream(remainTokens)
.on('start', () => {
p = this.templates
this.cond = tagToken.args
this.cond = new Value(tagToken.args, this.liquid)
})
.on('tag:elsif', (token: TagToken) => {
this.branches.push({
cond: token.args,
cond: new Value(token.args, this.liquid),
templates: p = []
})
})
@@ -29,8 +29,7 @@ export default {
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
const { operators, operatorsTrie } = this.liquid.options
const cond = yield new Expression(this.cond, operators, operatorsTrie, ctx.opts.lenientIf).value(ctx)
const cond = yield this.cond.value(ctx, ctx.opts.lenientIf)
if (isFalsy(cond, ctx)) {
yield r.renderTemplates(this.templates, ctx, emitter)
@@ -38,7 +37,7 @@ export default {
}
for (const branch of this.branches) {
const cond = yield new Expression(branch.cond, operators, operatorsTrie, ctx.opts.lenientIf).value(ctx)
const cond = yield branch.cond.value(ctx, ctx.opts.lenientIf)
if (isTruthy(cond, ctx)) {
yield r.renderTemplates(branch.templates, ctx, emitter)
return
+2 -2
View File
@@ -101,8 +101,8 @@ export class Liquid {
}
public _evalValue (str: string, ctx: Context): IterableIterator<any> {
const value = new Value(str, this.filters, this)
return value.value(ctx)
const value = new Value(str, this)
return value.value(ctx, false)
}
public async evalValue (str: string, ctx: Context): Promise<any> {
return toPromise(this._evalValue(str, ctx))
+1 -1
View File
@@ -29,7 +29,7 @@ export default class Parser {
return new Tag(token, remainTokens, this.liquid)
}
if (isOutputToken(token)) {
return new Output(token as OutputToken, this.liquid.filters, this.liquid)
return new Output(token as OutputToken, this.liquid)
}
return new HTML(token)
} catch (e) {
+6 -1
View File
@@ -23,6 +23,7 @@ import { NormalizedFullOptions, defaultOptions } from '../liquid-options'
import { TYPES, QUOTE, BLANK, IDENTIFIER } from '../util/character'
import { matchOperator } from './match-operator'
import { Trie } from '../util/operator-trie'
import { Expression } from '../render/expression'
export class Tokenizer {
p = 0
@@ -37,7 +38,11 @@ export class Tokenizer {
this.N = input.length
}
* readExpression (): IterableIterator<Token> {
readExpression () {
return new Expression(this.readExpressionTokens())
}
* readExpressionTokens (): IterableIterator<Token> {
const operand = this.readValue()
if (!operand) return
+25 -35
View File
@@ -1,4 +1,5 @@
import { QuotedToken } from '../tokens/quoted-token'
import { PropertyAccessToken } from '../tokens/property-access-token'
import { NumberToken } from '../tokens/number-token'
import { assert } from '../util/assert'
import { literalValues } from '../util/literal'
@@ -9,57 +10,35 @@ import { OperatorToken } from '../tokens/operator-token'
import { RangeToken } from '../tokens/range-token'
import { parseStringLiteral } from '../parser/parse-string-literal'
import { Context } from '../context/context'
import { range, toValue } from '../util/underscore'
import { Tokenizer } from '../parser/tokenizer'
import { range } from '../util/underscore'
import { Operators } from '../render/operator'
import { UndefinedVariableError, InternalUndefinedVariableError } from '../util/error'
import { Trie } from '../util/operator-trie'
export class Expression {
private operands: any[] = []
private postfix: Token[]
private lenient: boolean
private operators: Operators
public constructor (str: string, operators: Operators, operatorsTrie: Trie, lenient = false) {
const tokenizer = new Tokenizer(str, operatorsTrie)
this.postfix = [...toPostfix(tokenizer.readExpression())]
this.lenient = lenient
this.operators = operators
public constructor (tokens: IterableIterator<Token>) {
this.postfix = [...toPostfix(tokens)]
}
public evaluate (ctx: Context): any {
public * evaluate (ctx: Context, lenient: boolean): any {
assert(ctx, () => 'unable to evaluate: context not defined')
const operands: any[] = []
for (const token of this.postfix) {
if (TypeGuards.isOperatorToken(token)) {
const r = this.operands.pop()
const l = this.operands.pop()
const result = evalOperatorToken(this.operators, token, l, r, ctx)
this.operands.push(result)
const r = yield operands.pop()
const l = yield operands.pop()
const result = evalOperatorToken(ctx.opts.operators, token, l, r, ctx)
operands.push(result)
} else {
this.operands.push(evalToken(token, ctx, this.lenient && this.postfix.length === 1))
operands.push(yield evalToken(token, ctx, lenient && this.postfix.length === 1))
}
}
return this.operands[0]
}
public * value (ctx: Context) {
return toValue(this.evaluate(ctx))
return operands[0]
}
}
export function evalToken (token: Token | undefined, ctx: Context, lenient = false): any {
assert(ctx, () => 'unable to evaluate: context not defined')
if (TypeGuards.isPropertyAccessToken(token)) {
const variable = token.getVariableAsText()
const props: string[] = token.props.map(prop => evalToken(prop, ctx))
try {
return ctx.get([variable, ...props])
} catch (e) {
if (lenient && e instanceof InternalUndefinedVariableError) {
return null
} else {
throw (new UndefinedVariableError(e, token))
}
}
}
if (TypeGuards.isPropertyAccessToken(token)) return evalPropertyAccessToken(token, ctx, lenient)
if (TypeGuards.isRangeToken(token)) return evalRangeToken(token, ctx)
if (TypeGuards.isLiteralToken(token)) return evalLiteralToken(token)
if (TypeGuards.isNumberToken(token)) return evalNumberToken(token)
@@ -67,6 +46,17 @@ export function evalToken (token: Token | undefined, ctx: Context, lenient = fal
if (TypeGuards.isQuotedToken(token)) return evalQuotedToken(token)
}
function evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean) {
const variable = token.getVariableAsText()
const props: string[] = token.props.map(prop => evalToken(prop, ctx, false))
try {
return ctx.get([variable, ...props])
} catch (e) {
if (lenient && e instanceof InternalUndefinedVariableError) return null
throw (new UndefinedVariableError(e, token))
}
}
function evalNumberToken (token: NumberToken) {
const str = token.whole.content + '.' + (token.decimal ? token.decimal.content : '')
return Number(str)
+4 -4
View File
@@ -17,12 +17,12 @@ export class Filter {
this.args = args
this.liquid = liquid
}
public * render (value: any, context: Context) {
public render (value: any, context: Context) {
const argv: any[] = []
for (const arg of this.args as FilterArg[]) {
if (isKeyValuePair(arg)) argv.push([arg[0], yield evalToken(arg[1], context)])
else argv.push(yield evalToken(arg, context))
if (isKeyValuePair(arg)) argv.push([arg[0], evalToken(arg[1], context)])
else argv.push(evalToken(arg, context))
}
return yield this.impl.apply({ context, liquid: this.liquid }, [value, ...argv])
return this.impl.apply({ context, liquid: this.liquid }, [value, ...argv])
}
}
+3 -4
View File
@@ -1,5 +1,4 @@
import { Value } from './value'
import { FilterMap } from './filter/filter-map'
import { TemplateImpl } from '../template/template-impl'
import { Template } from '../template/template'
import { Context } from '../context/context'
@@ -9,12 +8,12 @@ import { Liquid } from '../liquid'
export class Output extends TemplateImpl<OutputToken> implements Template {
private value: Value
public constructor (token: OutputToken, filters: FilterMap, liquid: Liquid) {
public constructor (token: OutputToken, liquid: Liquid) {
super(token)
this.value = new Value(token.content, filters, liquid)
this.value = new Value(token.content, liquid)
}
public * render (ctx: Context, emitter: Emitter) {
const val = yield this.value.value(ctx)
const val = yield this.value.value(ctx, false)
emitter.write(val)
}
}
+8 -10
View File
@@ -1,27 +1,25 @@
import { evalToken } from '../render/expression'
import { Expression } from '../render/expression'
import { Tokenizer } from '../parser/tokenizer'
import { FilterMap } from '../template/filter/filter-map'
import { Filter } from './filter/filter'
import { Context } from '../context/context'
import { ValueToken } from '../tokens/value-token'
import { Liquid } from '../liquid'
export class Value {
public readonly filters: Filter[] = []
public readonly initial?: ValueToken
public readonly initial: Expression
/**
* @param str the value to be valuated, eg.: "foobar" | truncate: 3
*/
public constructor (str: string, private readonly filterMap: FilterMap, liquid: Liquid) {
public constructor (str: string, liquid: Liquid) {
const tokenizer = new Tokenizer(str, liquid.options.operatorsTrie)
this.initial = tokenizer.readValue()
this.filters = tokenizer.readFilters().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args, liquid))
this.initial = tokenizer.readExpression()
this.filters = tokenizer.readFilters().map(({ name, args }) => new Filter(name, liquid.filters.get(name), args, liquid))
}
public * value (ctx: Context) {
const lenient = ctx.opts.lenientIf && this.filters.length > 0 && this.filters[0].name === 'default'
public * value (ctx: Context, lenient: boolean) {
lenient = lenient || (ctx.opts.lenientIf && this.filters.length > 0 && this.filters[0].name === 'default')
let val = yield this.initial.evaluate(ctx, lenient)
let val = yield evalToken(this.initial, ctx, lenient)
for (const filter of this.filters) {
val = yield filter.render(val, ctx)
}
+4 -1
View File
@@ -17,6 +17,9 @@ export { Token } from './tokens/token'
export { TopLevelToken } from './tokens/toplevel-token'
export { Tokenizer } from './parser/tokenizer'
export { Hash } from './template/tag/hash'
export { Value } from './template/value'
export { evalToken, evalQuotedToken } from './render/expression'
export { toPromise, toThenable, toValue } from './util/async'
export { toPromise, toThenable } from './util/async'
export { defaultOperators, Operators } from './render/operator'
export { createTrie, Trie } from './util/operator-trie'
export { toValue } from './util/underscore'
+2 -2
View File
@@ -76,9 +76,9 @@ describe('tags/assign', function () {
return expect(html).to.equal('11')
})
it('should write to the root scope', async function () {
const src = '{%for a in (1..2)%}{%assign num = a%}{{a}}{%endfor%} {{num}}'
const src = '{%for a in (1..2)%}{%assign num = a%}{{a}}{%endfor%}'
const html = await liquid.parseAndRender(src, { num: 1 })
return expect(html).to.equal('12 2')
return expect(html).to.equal('12')
})
it('should not change input scope', async function () {
const src = '{%for a in (1..2)%}{%assign num = a%}{{a}}{%endfor%} {{num}}'
+8
View File
@@ -80,6 +80,14 @@ describe('tags/if', function () {
return expect(html).to.equal('success')
})
})
describe('filters as condition', function () {
it('should support filter on expression', async function () {
liquid.registerFilter('negate', (val) => !val)
const src = '{% if 2 == 3 | negate %}yes{%else%}no{%endif%}'
const html = await liquid.parseAndRender(src, scope)
return expect(html).to.equal('yes')
})
})
describe('compare to null', function () {
it('should evaluate false for null < 10', async function () {
const src = '{% if null < 10 %}yes{% else %}no{% endif %}'
+9 -1
View File
@@ -1,5 +1,9 @@
import { Liquid } from '../../../src/liquid'
import { expect } from 'chai'
import * as chai from 'chai'
import * as chaiAsPromised from 'chai-as-promised'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('LiquidOptions#strict*', function () {
let engine: Liquid
@@ -60,5 +64,9 @@ describe('LiquidOptions#strict*', function () {
const html = await engine.render(tpl, ctx, strictLenientOpts)
return expect(html).to.equal('a')
})
it('should not allow undefined variable even if `lenientIf` set', async function () {
const tpl = engine.parse('{{notdefined | tolower}}')
return expect(() => engine.renderSync(tpl, ctx, strictLenientOpts)).to.throw('undefined variable: notdefined')
})
})
})
+17 -18
View File
@@ -227,7 +227,6 @@ describe('Tokenizer', function () {
const tokenizer = new Tokenizer(html, trie)
const token = tokenizer.readOutputToken()
console.log(token)
expect(token).instanceOf(OutputToken)
expect(token.content).to.equal('"%} {%" | append: "}} {{"')
})
@@ -299,7 +298,7 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.equal('arr')
expect((pa.variable as any).content).to.equal('arr')
expect(pa.props).to.have.lengthOf(1)
expect(pa.props[0]).to.be.instanceOf(NumberToken)
expect(pa.props[0].getText()).to.equal('0')
@@ -312,7 +311,7 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.equal('obj')
expect((pa.variable as any).content).to.equal('obj')
expect(pa.props).to.have.lengthOf(1)
expect(pa.props[0]).to.be.instanceOf(IdentifierToken)
expect(pa.props[0].getText()).to.equal('foo')
@@ -326,7 +325,7 @@ describe('Tokenizer', function () {
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).to.be.instanceOf(PropertyAccessToken)
expect(pa.getText()).to.equal('obj["good luck"]')
expect(pa.variable.content).to.equal('obj')
expect((pa.variable as any).content).to.equal('obj')
expect(pa.props[0].getText()).to.equal('"good luck"')
})
})
@@ -368,19 +367,19 @@ describe('Tokenizer', function () {
})
describe('#readExpression()', () => {
it('should read expression `a `', () => {
const exp = [...new Tokenizer('a ', trie).readExpression()]
const exp = [...new Tokenizer('a ', trie).readExpressionTokens()]
expect(exp).to.have.lengthOf(1)
expect(exp[0]).to.be.instanceOf(PropertyAccessToken)
expect(exp[0].getText()).to.deep.equal('a')
})
it('should read expression `a[][b]`', () => {
const exp = [...new Tokenizer('a[][b]', trie).readExpression()]
const exp = [...new Tokenizer('a[][b]', trie).readExpressionTokens()]
expect(exp).to.have.lengthOf(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.deep.equal('a')
expect((pa.variable as any).content).to.deep.equal('a')
expect(pa.props).to.have.lengthOf(2)
const [p1, p2] = pa.props
@@ -390,23 +389,23 @@ describe('Tokenizer', function () {
expect(p2.getText()).to.equal('b')
})
it('should read expression `a.`', () => {
const exp = [...new Tokenizer('a.', trie).readExpression()]
const exp = [...new Tokenizer('a.', trie).readExpressionTokens()]
expect(exp).to.have.lengthOf(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).to.be.instanceOf(PropertyAccessToken)
expect(pa.variable.content).to.deep.equal('a')
expect((pa.variable as any).content).to.deep.equal('a')
expect(pa.props).to.have.lengthOf(0)
})
it('should read expression `a ==`', () => {
const exp = [...new Tokenizer('a ==', trie).readExpression()]
const exp = [...new Tokenizer('a ==', trie).readExpressionTokens()]
expect(exp).to.have.lengthOf(1)
expect(exp[0]).to.be.instanceOf(PropertyAccessToken)
expect(exp[0].getText()).to.deep.equal('a')
})
it('should read expression `a==b`', () => {
const exp = new Tokenizer('a==b', trie).readExpression()
const exp = new Tokenizer('a==b', trie).readExpressionTokens()
const [a, equals, b] = exp
expect(a).to.be.instanceOf(PropertyAccessToken)
@@ -419,11 +418,11 @@ describe('Tokenizer', function () {
expect(b.getText()).to.deep.equal('b')
})
it('should read expression `^`', () => {
const exp = new Tokenizer('^', trie).readExpression()
const exp = new Tokenizer('^', trie).readExpressionTokens()
expect([...exp]).to.deep.equal([])
})
it('should read expression `a == b`', () => {
const exp = new Tokenizer('a == b', trie).readExpression()
const exp = new Tokenizer('a == b', trie).readExpressionTokens()
const [a, equals, b] = exp
expect(a).to.be.instanceOf(PropertyAccessToken)
@@ -436,7 +435,7 @@ describe('Tokenizer', function () {
expect(b.getText()).to.deep.equal('b')
})
it('should read expression `(1..3) contains 3`', () => {
const exp = new Tokenizer('(1..3) contains 3', trie).readExpression()
const exp = new Tokenizer('(1..3) contains 3', trie).readExpressionTokens()
const [range, contains, rhs] = exp
expect(range).to.be.instanceOf(RangeToken)
@@ -449,7 +448,7 @@ describe('Tokenizer', function () {
expect(rhs.getText()).to.deep.equal('3')
})
it('should read expression `a[b] == c`', () => {
const exp = new Tokenizer('a[b] == c', trie).readExpression()
const exp = new Tokenizer('a[b] == c', trie).readExpressionTokens()
const [lhs, contains, rhs] = exp
expect(lhs).to.be.instanceOf(PropertyAccessToken)
@@ -462,7 +461,7 @@ describe('Tokenizer', function () {
expect(rhs.getText()).to.deep.equal('c')
})
it('should read expression `c[a["b"]] >= c`', () => {
const exp = new Tokenizer('c[a["b"]] >= c', trie).readExpression()
const exp = new Tokenizer('c[a["b"]] >= c', trie).readExpressionTokens()
const [lhs, op, rhs] = exp
expect(lhs).to.be.instanceOf(PropertyAccessToken)
@@ -475,7 +474,7 @@ describe('Tokenizer', function () {
expect(rhs.getText()).to.deep.equal('c')
})
it('should read expression `"][" == var`', () => {
const exp = new Tokenizer('"][" == var', trie).readExpression()
const exp = new Tokenizer('"][" == var', trie).readExpressionTokens()
const [lhs, equals, rhs] = exp
expect(lhs).to.be.instanceOf(QuotedToken)
@@ -488,7 +487,7 @@ describe('Tokenizer', function () {
expect(rhs.getText()).to.deep.equal('var')
})
it('should read expression `"\\\'" == "\\""`', () => {
const exp = new Tokenizer('"\\\'" == "\\""', trie).readExpression()
const exp = new Tokenizer('"\\\'" == "\\""', trie).readExpressionTokens()
const [lhs, equals, rhs] = exp
expect(lhs).to.be.instanceOf(QuotedToken)
+43 -42
View File
@@ -1,4 +1,4 @@
import { Expression } from '../../../src/render/expression'
import { Tokenizer } from '../../../src/parser/tokenizer'
import { expect } from 'chai'
import { Context } from '../../../src/context/context'
import { toThenable } from '../../../src/util/async'
@@ -8,9 +8,10 @@ import { createTrie } from '../../../src/util/operator-trie'
describe('Expression', function () {
const ctx = new Context({})
const trie = createTrie(defaultOperators)
const create = (str: string) => new Tokenizer(str, trie).readExpression()
it('should throw when context not defined', done => {
toThenable(new Expression('foo', defaultOperators, trie).value(undefined!))
toThenable(create('foo').evaluate(undefined!, false))
.then(() => done(new Error('should not resolved')))
.catch(err => {
expect(err.message).to.match(/context not defined/)
@@ -20,19 +21,19 @@ describe('Expression', function () {
describe('single value', function () {
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4', defaultOperators, trie).value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"', defaultOperators, trie).value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('2.4').evaluate(ctx, false))).to.equal(2.4)
expect(await toThenable(create('"foo"').evaluate(ctx, false))).to.equal('foo')
expect(await toThenable(create('false').evaluate(ctx, false))).to.equal(false)
})
it('should eval range expression', async function () {
const ctx = new Context({ two: 2 })
expect(await toThenable(new Expression('(2..4)', defaultOperators, trie).value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(new Expression('(two..4)', defaultOperators, trie).value(ctx))).to.deep.equal([2, 3, 4])
expect(await toThenable(create('(2..4)').evaluate(ctx, false))).to.deep.equal([2, 3, 4])
expect(await toThenable(create('(two..4)').evaluate(ctx, false))).to.deep.equal([2, 3, 4])
})
it('should eval literal', async function () {
expect(await toThenable(new Expression('2.4', defaultOperators, trie).value(ctx))).to.equal(2.4)
expect(await toThenable(new Expression('"foo"', defaultOperators, trie).value(ctx))).to.equal('foo')
expect(await toThenable(new Expression('false', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('2.4').evaluate(ctx, false))).to.equal(2.4)
expect(await toThenable(create('"foo"').evaluate(ctx, false))).to.equal('foo')
expect(await toThenable(create('false').evaluate(ctx, false))).to.equal(false)
})
it('should eval property access', async function () {
@@ -41,112 +42,112 @@ describe('Expression', function () {
coo: 'bar',
doo: { foo: 'bar', bar: { foo: 'bar' } }
})
expect(await toThenable(new Expression('foo.bar', defaultOperators, trie).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo["bar"]', defaultOperators, trie).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[coo]', defaultOperators, trie).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo.foo]', defaultOperators, trie).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('foo[doo["foo"]]', defaultOperators, trie).value(ctx))).to.equal('BAR')
expect(await toThenable(new Expression('doo[coo].foo', defaultOperators, trie).value(ctx))).to.equal('bar')
expect(await toThenable(create('foo.bar').evaluate(ctx, false))).to.equal('BAR')
expect(await toThenable(create('foo["bar"]').evaluate(ctx, false))).to.equal('BAR')
expect(await toThenable(create('foo[coo]').evaluate(ctx, false))).to.equal('BAR')
expect(await toThenable(create('foo[doo.foo]').evaluate(ctx, false))).to.equal('BAR')
expect(await toThenable(create('foo[doo["foo"]]').evaluate(ctx, false))).to.equal('BAR')
expect(await toThenable(create('doo[coo].foo').evaluate(ctx, false))).to.equal('bar')
})
})
describe('simple expression', function () {
it('should return false for "1==2"', async () => {
expect(await toThenable(new Expression('1==2', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('1==2').evaluate(ctx, false))).to.equal(false)
})
it('should return true for "1<2"', async () => {
expect(await toThenable(new Expression('1<2', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('1<2').evaluate(ctx, false))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('1 < 2').evaluate(ctx, false))).to.equal(true)
})
it('should return true for "1 < 2"', async () => {
expect(await toThenable(new Expression('1 < 2', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('1 < 2').evaluate(ctx, false))).to.equal(true)
})
it('should return true for "2 <= 2"', async () => {
expect(await toThenable(new Expression('2 <= 2', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('2 <= 2').evaluate(ctx, false))).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', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('one <= two').evaluate(ctx, false))).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"', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('x contains "x"').evaluate(ctx, false))).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"', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('x contains "X"').evaluate(ctx, false))).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"', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('1 contains "x"').evaluate(ctx, false))).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"', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('y contains "x"').evaluate(ctx, false))).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"', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('z contains "x"').evaluate(ctx, false))).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', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('(1..5) contains 3').evaluate(ctx, false))).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', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('(1..5) contains 6').evaluate(ctx, false))).to.equal(false)
})
it('should return true for ""<=" == "<=""', async () => {
expect(await toThenable(new Expression('"<=" == "<="', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('"<=" == "<="').evaluate(ctx, false))).to.equal(true)
})
})
it('should allow space in quoted value', async function () {
const ctx = new Context({ space: ' ' })
expect(await toThenable(new Expression('" " == space', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('" " == space').evaluate(ctx, false))).to.equal(true)
})
describe('escape', () => {
it('should escape quote', async function () {
const ctx = new Context({ quote: '"' })
expect(await toThenable(new Expression('"\\"" == quote', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('"\\"" == quote').evaluate(ctx, false))).to.equal(true)
})
it('should escape square bracket', async function () {
const ctx = new Context({ obj: { ']': 'bracket' } })
expect(await toThenable(new Expression('obj["]"] == "bracket"', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('obj["]"] == "bracket"').evaluate(ctx, false))).to.equal(true)
})
})
describe('complex expression', function () {
it('should support value or value', async function () {
expect(await toThenable(new Expression('false or true', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('false or true').evaluate(ctx, false))).to.equal(true)
})
it('should support < and contains', async function () {
expect(await toThenable(new Expression('1 < 2 and x contains "x"', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('1 < 2 and x contains "x"').evaluate(ctx, false))).to.equal(false)
})
it('should support < or contains', async function () {
expect(await toThenable(new Expression('1 < 2 or x contains "x"', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('1 < 2 or x contains "x"').evaluate(ctx, false))).to.equal(true)
})
it('should support value and !=', async function () {
const ctx = new Context({ empty: '' })
expect(await toThenable(new Expression('empty and empty != ""', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('empty and empty != ""').evaluate(ctx, false))).to.equal(false)
})
it('should recognize quoted value', async function () {
expect(await toThenable(new Expression('">"', defaultOperators, trie).value(ctx))).to.equal('>')
expect(await toThenable(create('">"').evaluate(ctx, false))).to.equal('>')
})
it('should evaluate from right to left', async function () {
expect(await toThenable(new Expression('true or false and false', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(new Expression('true and false and false or true', defaultOperators, trie).value(ctx))).to.equal(false)
expect(await toThenable(create('true or false and false').evaluate(ctx, false))).to.equal(true)
expect(await toThenable(create('true and false and false or true').evaluate(ctx, false))).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', defaultOperators, trie).value(ctx))).to.equal(true)
expect(await toThenable(create('obj["foo"] and true').evaluate(ctx, false))).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"]]', defaultOperators, trie).value(ctx))).to.equal('FOO')
expect(await toThenable(create('obj[keys["what\'s this"]]').evaluate(ctx, false))).to.equal('FOO')
})
})
})
+10 -18
View File
@@ -3,7 +3,6 @@ import { toThenable } from '../../../src/util/async'
import { Context } from '../../../src/context/context'
import { Output } from '../../../src/template/output'
import { OutputToken } from '../../../src/tokens/output-token'
import { FilterMap } from '../../../src/template/filter/filter-map'
import { defaultOptions } from '../../../src/liquid-options'
import { createTrie } from '../../../src/util/operator-trie'
import { defaultOperators } from '../../../src/types'
@@ -15,35 +14,31 @@ describe('Output', function () {
const liquid = {
options: { operatorsTrie: createTrie(defaultOperators) }
} as any
let filters: FilterMap
beforeEach(function () {
filters = new FilterMap(false, liquid)
emitter.html = ''
})
beforeEach(() => { emitter.html = '' })
it('should stringify objects', async function () {
const scope = new Context({
foo: { obj: { arr: ['a', 2] } }
})
const output = new Output({ content: 'foo' } as OutputToken, filters, liquid)
const output = new Output({ content: 'foo' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal('[object Object]')
})
it('should skip function property', async function () {
const scope = new Context({ obj: { foo: 'foo', bar: (x: any) => x } })
const output = new Output({ content: 'obj' } as OutputToken, filters, liquid)
const output = new Output({ content: 'obj' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal('[object Object]')
})
it('should respect to .toString()', async () => {
const scope = new Context({ obj: { toString: () => 'FOO' } })
const output = new Output({ content: 'obj' } as OutputToken, filters, liquid)
const output = new Output({ content: 'obj' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal('FOO')
})
it('should respect to .toString()', async () => {
const scope = new Context({ obj: { toString: () => 'FOO' } })
const output = new Output({ content: 'obj' } as OutputToken, filters, liquid)
const output = new Output({ content: 'obj' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal('FOO')
})
@@ -60,16 +55,13 @@ describe('Output', function () {
keepOutputType: true
}
beforeEach(function () {
filters = new FilterMap(false, liquid)
emitter.html = ''
})
beforeEach(() => { emitter.html = '' })
it('should respect output variable number type', async () => {
const scope = new Context({
foo: 42
}, { ...defaultOptions, keepOutputType: true })
const output = new Output({ content: 'foo' } as OutputToken, filters, liquid)
const output = new Output({ content: 'foo' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal(42)
})
@@ -77,7 +69,7 @@ describe('Output', function () {
const scope = new Context({
foo: true
}, { ...defaultOptions, keepOutputType: true })
const output = new Output({ content: 'foo' } as OutputToken, filters, liquid)
const output = new Output({ content: 'foo' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal(true)
})
@@ -85,7 +77,7 @@ describe('Output', function () {
const scope = new Context({
foo: 'test'
}, { ...defaultOptions, keepOutputType: true })
const output = new Output({ content: 'foo' } as OutputToken, filters, liquid)
const output = new Output({ content: 'foo' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.equal('test')
})
@@ -93,7 +85,7 @@ describe('Output', function () {
const scope = new Context({
foo: { a: { b: 42 } }
}, { ...defaultOptions, keepOutputType: true })
const output = new Output({ content: 'foo' } as OutputToken, filters, liquid)
const output = new Output({ content: 'foo' } as OutputToken, liquid)
await toThenable(output.render(scope, emitter))
return expect(emitter.html).to.deep.equal({ a: { b: 42 } })
})
+7 -18
View File
@@ -1,32 +1,22 @@
import * as chai from 'chai'
import { Liquid } from '../../../src/liquid'
import { QuotedToken } from '../../../src/tokens/quoted-token'
import { toThenable } from '../../../src/util/async'
import { FilterMap } from '../../../src/template/filter/filter-map'
import * as sinonChai from 'sinon-chai'
import * as sinon from 'sinon'
import { Context } from '../../../src/context/context'
import { Value } from '../../../src/template/value'
import { createTrie } from '../../../src/util/operator-trie'
import { defaultOperators } from '../../../src/types'
chai.use(sinonChai)
const expect = chai.expect
describe('Value', function () {
const liquid = {
options: { operatorsTrie: createTrie(defaultOperators) }
} as any
const liquid = new Liquid()
describe('#constructor()', function () {
const filterMap = new FilterMap(false, liquid)
it('should parse "foo', function () {
const tpl = new Value('foo', filterMap, liquid)
expect(tpl.initial!.getText()).to.equal('foo')
expect(tpl.filters).to.deep.equal([])
})
it('should parse filters in value content', function () {
const f = new Value('o | foo: a: "a"', filterMap, liquid)
const f = new Value('o | foo: a: "a"', liquid)
expect(f.filters[0].name).to.equal('foo')
expect(f.filters[0].args).to.have.lengthOf(1)
const [k, v] = f.filters[0].args[0] as any
@@ -40,14 +30,13 @@ describe('Value', function () {
it('should call chained filters correctly', async function () {
const date = sinon.stub().returns('y')
const time = sinon.spy()
const filterMap = new FilterMap(false, liquid)
filterMap.set('date', date)
filterMap.set('time', time)
const tpl = new Value('foo.bar | date: "b" | time:2', filterMap, liquid)
liquid.registerFilter('date', date)
liquid.registerFilter('time', time)
const tpl = new Value('foo.bar | date: "b" | time:2', liquid)
const scope = new Context({
foo: { bar: 'bar' }
})
await toThenable(tpl.value(scope))
await toThenable(tpl.value(scope, false))
expect(date).to.have.been.calledWith('bar', 'b')
expect(time).to.have.been.calledWith('y', 2)
})