Skip to content

Commit

Permalink
feat: support filters in if/unless/case, see #287
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Feb 12, 2021
1 parent 4e82da6 commit 2f059f6
Show file tree
Hide file tree
Showing 18 changed files with 164 additions and 176 deletions.
14 changes: 6 additions & 8 deletions src/builtin/tags/case.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
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 = []

let p: Template[] = []
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 = []
})
})
Expand All @@ -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
Expand Down
9 changes: 4 additions & 5 deletions src/builtin/tags/if.ts
Original file line number Diff line number Diff line change
@@ -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[]) {
Expand All @@ -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 = []
})
})
Expand All @@ -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
Expand Down
11 changes: 5 additions & 6 deletions src/builtin/tags/unless.ts
Original file line number Diff line number Diff line change
@@ -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[]) {
Expand All @@ -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 = []
})
})
Expand All @@ -29,16 +29,15 @@ 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)
return
}

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
Expand Down
4 changes: 2 additions & 2 deletions src/liquid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion src/parser/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
60 changes: 25 additions & 35 deletions src/render/expression.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,64 +10,53 @@ 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)
if (TypeGuards.isWordToken(token)) return token.getText()
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)
Expand Down
8 changes: 4 additions & 4 deletions src/template/filter/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
7 changes: 3 additions & 4 deletions src/template/output.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}
}
18 changes: 8 additions & 10 deletions src/template/value.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 2 additions & 2 deletions test/integration/builtin/tags/assign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}'
Expand Down
8 changes: 8 additions & 0 deletions test/integration/builtin/tags/if.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}'
Expand Down
10 changes: 9 additions & 1 deletion test/integration/liquid/strict.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
})
})
})
Loading

0 comments on commit 2f059f6

Please sign in to comment.