diff --git a/src/liquid-options.ts b/src/liquid-options.ts index a1e0620887..4eb9a4abdf 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -84,6 +84,8 @@ export interface LiquidOptions { operators?: Operators; /** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */ orderedFilterParameters?: boolean; + /** Allow parenthesized expressions as operands in conditions and loops, e.g. `{% if (foo | upcase) == "BAR" %}`. This is a non-standard extension to Liquid. Defaults to `false`. */ + groupedExpressions?: boolean; /** For DoS handling, limit total length of templates parsed in one `parse()` call. A typical PC can handle 1e8 (100M) characters without issues. */ parseLimit?: number; /** For DoS handling, limit total time (in ms) for each `render()` call. */ @@ -159,6 +161,7 @@ export interface NormalizedFullOptions extends NormalizedOptions { globals: object; keepOutputType: boolean; operators: Operators; + groupedExpressions: boolean; parseLimit: number; renderLimit: number; memoryLimit: number; @@ -195,6 +198,7 @@ export const defaultOptions: NormalizedFullOptions = { globals: {}, keepOutputType: false, operators: defaultOperators, + groupedExpressions: false, memoryLimit: Infinity, parseLimit: Infinity, renderLimit: Infinity diff --git a/src/parser/parser.ts b/src/parser/parser.ts index f61f2323ee..0485f35c38 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -33,7 +33,7 @@ export class Parser { public parse (html: string, filepath?: string): Template[] { html = String(html) this.parseLimit.use(html.length) - const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath) + const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath, undefined, this.liquid.options.groupedExpressions) const tokens = tokenizer.readTopLevelTokens(this.liquid.options) return this.parseTokens(tokens) } diff --git a/src/parser/token-kind.ts b/src/parser/token-kind.ts index f091519ab4..a29017c9d6 100644 --- a/src/parser/token-kind.ts +++ b/src/parser/token-kind.ts @@ -12,5 +12,6 @@ export enum TokenKind { Quoted = 1024, Operator = 2048, FilteredValue = 4096, + GroupedExpression = 8192, Delimited = Tag | Output } diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index c8d4f6311f..d13ac45f56 100644 --- a/src/parser/tokenizer.spec.ts +++ b/src/parser/tokenizer.spec.ts @@ -1,4 +1,4 @@ -import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken } from '../tokens' +import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken, GroupedExpressionToken } from '../tokens' import { Tokenizer } from './tokenizer' import { defaultOperators } from '../render/operator' import { createTrie } from '../util/operator-trie' @@ -229,22 +229,80 @@ describe('Tokenizer', function () { }) describe('#readRange()', () => { it('should read `(1..3)`', () => { - const range = new Tokenizer('(1..3)').readRange() + const result = new Tokenizer('(1..3)').readGroupOrRange() + expect(result).toBeDefined() + expect(result!.type).toBe('range') + const { range } = result as { type: 'range', range: RangeToken } expect(range).toBeInstanceOf(RangeToken) - expect(range!.getText()).toEqual('(1..3)') - const { lhs, rhs } = range! - expect(lhs).toBeInstanceOf(NumberToken) - expect(lhs.getText()).toBe('1') - expect(rhs).toBeInstanceOf(NumberToken) - expect(rhs.getText()).toBe('3') + expect(range.getText()).toEqual('(1..3)') + expect(range.lhs).toBeInstanceOf(NumberToken) + expect(range.lhs.getText()).toBe('1') + expect(range.rhs).toBeInstanceOf(NumberToken) + expect(range.rhs.getText()).toBe('3') }) it('should throw for `(..3)`', () => { - expect(() => new Tokenizer('(..3)').readRange()).toThrow('unexpected token "..3)", value expected') + expect(() => new Tokenizer('(..3)').readGroupOrRange()).toThrow('unexpected token "..3)", value expected') }) it('should read `(a.b..c["..d"])`', () => { - const range = new Tokenizer('(a.b..c["..d"])').readRange() - expect(range).toBeInstanceOf(RangeToken) - expect(range!.getText()).toEqual('(a.b..c["..d"])') + const wrappedToken = new Tokenizer('(a.b..c["..d"])').readGroupOrRange() as { type: 'range', range: RangeToken } + expect(wrappedToken).toBeDefined() + expect(wrappedToken.type).toBe('range') + + const result = wrappedToken as { type: 'range', range: RangeToken } + + expect(result.range).toBeInstanceOf(RangeToken) + expect(result.range.getText()).toEqual('(a.b..c["..d"])') + }) + }) + describe('#readGroupedExpression()', () => { + function createGrouped (input: string): Tokenizer { + const t = new Tokenizer(input, defaultOperators) + t.groupedExpressions = true + return t + } + it('should read `(foo | upcase)` as GroupedExpressionToken', () => { + const token = createGrouped('(foo | upcase)').readValue() + expect(token).toBeInstanceOf(GroupedExpressionToken) + const grouped = token as GroupedExpressionToken + expect(grouped.getText()).toBe('(foo | upcase)') + expect(grouped.initial.postfix).toHaveLength(1) + expect(grouped.filters).toHaveLength(1) + expect(grouped.filters[0].name).toBe('upcase') + }) + it('should read `(foo | append: "!")` with filter argument', () => { + const token = createGrouped('(foo | append: "!")').readValue() + expect(token).toBeInstanceOf(GroupedExpressionToken) + const grouped = token as GroupedExpressionToken + expect(grouped.filters).toHaveLength(1) + expect(grouped.filters[0].name).toBe('append') + expect(grouped.filters[0].args).toHaveLength(1) + }) + it('should read nested `((foo | append: "!") | upcase)`', () => { + const token = createGrouped('((foo | append: "!") | upcase)').readValue() + expect(token).toBeInstanceOf(GroupedExpressionToken) + const grouped = token as GroupedExpressionToken + expect(grouped.filters).toHaveLength(1) + expect(grouped.filters[0].name).toBe('upcase') + expect(grouped.initial.postfix).toHaveLength(1) + expect(grouped.initial.postfix[0]).toBeInstanceOf(GroupedExpressionToken) + }) + it('should parse `(a | upcase) == "BAR"` as expression', () => { + const exp = [...createGrouped('(a | upcase) == "BAR"').readExpressionTokens()] + expect(exp).toHaveLength(3) + expect(exp[0]).toBeInstanceOf(GroupedExpressionToken) + expect(exp[1]).toBeInstanceOf(OperatorToken) + expect(exp[1].getText()).toBe('==') + expect(exp[2]).toBeInstanceOf(QuotedToken) + }) + it('should still parse `(1..3)` as RangeToken', () => { + const token = createGrouped('(1..3)').readValue() + expect(token).toBeInstanceOf(RangeToken) + }) + it('should throw for unclosed parens', () => { + expect(() => createGrouped('(foo | upcase').readValue()).toThrow('unbalanced parentheses') + }) + it('should fall back to readRange when flag is off', () => { + expect(() => new Tokenizer('(foo | upcase)', defaultOperators).readValue()).toThrow('invalid range syntax') }) }) describe('#readFilter()', () => { diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 0c2d86df76..3f3de67327 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -1,4 +1,4 @@ -import { FilteredValueToken, TagToken, HTMLToken, HashToken, QuotedToken, LiquidTagToken, OutputToken, ValueToken, Token, RangeToken, FilterToken, TopLevelToken, PropertyAccessToken, OperatorToken, LiteralToken, IdentifierToken, NumberToken } from '../tokens' +import { FilteredValueToken, TagToken, HTMLToken, HashToken, QuotedToken, LiquidTagToken, OutputToken, ValueToken, Token, RangeToken, FilterToken, TopLevelToken, PropertyAccessToken, OperatorToken, LiteralToken, IdentifierToken, NumberToken, GroupedExpressionToken } from '../tokens' import { OperatorHandler } from '../render/operator' import { TrieNode, LiteralValue, Trie, createTrie, ellipsis, literalValues, TokenizationError, TYPES, QUOTE, BLANK, NUMBER, SIGN, isWord, isString } from '../util' import { Operators, Expression } from '../render' @@ -9,6 +9,7 @@ import { whiteSpaceCtrl } from './whitespace-ctrl' export class Tokenizer { p: number N: number + public groupedExpressions: boolean private rawBeginAt = -1 private opTrie: Trie private literalTrie: Trie @@ -17,12 +18,14 @@ export class Tokenizer { public input: string, operators: Operators = defaultOptions.operators, public file?: string, - range?: [number, number] + range?: [number, number], + groupedExpressions = false ) { this.p = range ? range[0] : 0 this.N = range ? range[1] : input.length this.opTrie = createTrie(operators) this.literalTrie = createTrie(literalValues) + this.groupedExpressions = groupedExpressions } readExpression () { @@ -80,6 +83,7 @@ export class Tokenizer { readFilter (): FilterToken | null { this.skipBlank() if (this.end()) return null + if (this.peek() === ')') return null this.assert(this.read() === '|', `expected "|" before filter`) const name = this.readIdentifier() if (!name.size()) { @@ -94,9 +98,9 @@ export class Tokenizer { const arg = this.readFilterArg() arg && args.push(arg) this.skipBlank() - this.assert(this.end() || this.peek() === ',' || this.peek() === '|', () => `unexpected character ${this.snapshot()}`) + this.assert(this.end() || this.peek() === ',' || this.peek() === '|' || this.peek() === ')', () => `unexpected character ${this.snapshot()}`) } while (this.peek() === ',') - } else if (this.peek() === '|' || this.end()) { + } else if (this.peek() === '|' || this.peek() === ')' || this.end()) { // do nothing } else { throw this.error('expected ":" after filter name') @@ -310,7 +314,16 @@ export class Tokenizer { readValue (): ValueToken | undefined { this.skipBlank() const begin = this.p - const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber() + let variable: ValueToken | undefined = this.readLiteral() || this.readQuoted() + if (!variable && this.peek() === '(') { + const rangeOrGroup = this.readGroupOrRange() + if (rangeOrGroup?.type === 'range') { + variable = rangeOrGroup.range + } else if (rangeOrGroup?.type === 'groupedExpression') { + variable = rangeOrGroup.groupedExpression + } + } + variable = variable || this.readNumber() const props = this.readProperties(!variable) if (!props.length) return variable return new PropertyAccessToken(variable, props, this.input, begin, this.p) @@ -385,18 +398,37 @@ export class Tokenizer { return literal } - readRange (): RangeToken | undefined { + readGroupOrRange (): { type: 'range', range: RangeToken } | { type: 'groupedExpression', groupedExpression: GroupedExpressionToken } | undefined { this.skipBlank() const begin = this.p if (this.peek() !== '(') return ++this.p const lhs = this.readValueOrThrow() this.skipBlank() - this.assert(this.read() === '.' && this.read() === '.', 'invalid range syntax') - const rhs = this.readValueOrThrow() - this.skipBlank() - this.assert(this.read() === ')', 'invalid range syntax') - return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file) + + if (this.peek() === '.' && this.peek(1) === '.') { + this.p += 2 + const rhs = this.readValueOrThrow() + this.skipBlank() + this.assert(this.read() === ')', 'invalid range syntax') + return { + type: 'range', + range: new RangeToken(this.input, begin, this.p, lhs, rhs, this.file) + } + } + + if (this.groupedExpressions) { + const expression = new Expression((function * () { yield lhs })()) + const filters = this.readFilters() + this.skipBlank() + this.assert(this.read() === ')', 'unbalanced parentheses') + return { + type: 'groupedExpression', + groupedExpression: new GroupedExpressionToken(expression, filters, this.input, begin, this.p, this.file) + } + } + + throw this.error('invalid range syntax') } readValueOrThrow (): ValueToken { diff --git a/src/render/expression.ts b/src/render/expression.ts index 62041ae174..9ae8827a77 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -1,5 +1,5 @@ -import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes } from '../tokens' -import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util' +import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, GroupedExpressionToken } from '../tokens' +import { isRangeToken, isPropertyAccessToken, isGroupedExpressionToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util' import type { Context } from '../context' import type { UnaryOperatorHandler } from '../render' import { Drop } from '../drop' @@ -40,6 +40,19 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f if ('content' in token) return token.content if (isPropertyAccessToken(token)) return yield evalPropertyAccessToken(token, ctx, lenient) if (isRangeToken(token)) return yield evalRangeToken(token, ctx) + if (isGroupedExpressionToken(token)) return yield evalGroupedExpressionToken(token, ctx, lenient) +} + +function * evalGroupedExpressionToken (token: GroupedExpressionToken, ctx: Context, lenient: boolean): IterableIterator { + assert(token.resolvedFilters, 'grouped expression filters not resolved') + lenient = lenient || (ctx.opts.lenientIf && token.filters.length > 0 && token.filters[0].name === 'default') + let val = yield token.initial.evaluate(ctx, lenient) + + for (const filter of token.resolvedFilters!) { + val = yield filter.render(val, ctx) + } + + return val } function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator { diff --git a/src/tags/case.ts b/src/tags/case.ts index 63e90c6f4d..64c8b9526e 100644 --- a/src/tags/case.ts +++ b/src/tags/case.ts @@ -1,7 +1,7 @@ import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream } from '..' import { Parser } from '../parser' import { equals } from '../render' -import { Arguments } from '../template' +import { Arguments, resolveGroupedExpressionFilters } from '../template' export default class extends Tag { value: Value @@ -24,7 +24,9 @@ export default class extends Tag { const values: ValueToken[] = [] while (!token.tokenizer.end()) { - values.push(token.tokenizer.readValueOrThrow()) + const val = token.tokenizer.readValueOrThrow() + resolveGroupedExpressionFilters(val, liquid) + values.push(val) token.tokenizer.skipBlank() if (token.tokenizer.peek() === ',') { token.tokenizer.readTo(',') diff --git a/src/tags/for.ts b/src/tags/for.ts index 779eee207b..8c9bfd620e 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -1,8 +1,9 @@ import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' +import { GroupedExpressionToken } from '../tokens' import { assertEmpty, isValueToken, toEnumerable } from '../util' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' -import { Arguments } from '../template' +import { Arguments, resolveGroupedExpressionFilters } from '../template' const MODIFIERS = ['offset', 'limit', 'reversed'] @@ -10,7 +11,7 @@ type valueOf = T[keyof T] export default class extends Tag { variable: string - collection: ValueToken + collection: ValueToken | GroupedExpressionToken hash: Hash templates: Template[] elseTemplates: Template[] @@ -26,6 +27,7 @@ export default class extends Tag { this.variable = variable.content this.collection = collection + resolveGroupedExpressionFilters(this.collection, liquid) this.hash = new Hash(this.tokenizer, liquid.options.keyValueSeparator) this.templates = [] this.elseTemplates = [] diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 91f8404451..b4f53c6a18 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -1,14 +1,15 @@ import { isValueToken, toEnumerable } from '../util' import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' +import { GroupedExpressionToken } from '../tokens' import { TablerowloopDrop } from '../drop/tablerowloop-drop' import { Parser } from '../parser' -import { Arguments } from '../template' +import { Arguments, resolveGroupedExpressionFilters } from '../template' export default class extends Tag { variable: string args: Hash templates: Template[] - collection: ValueToken + collection: ValueToken | GroupedExpressionToken constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) { super(tagToken, remainTokens, liquid) const variable = this.tokenizer.readIdentifier() @@ -22,6 +23,7 @@ export default class extends Tag { this.variable = variable.content this.collection = collectionToken + resolveGroupedExpressionFilters(this.collection, liquid) this.args = new Hash(this.tokenizer, liquid.options.keyValueSeparator) this.templates = [] diff --git a/src/template/analysis.ts b/src/template/analysis.ts index 0ad859bd6a..89d00ac307 100644 --- a/src/template/analysis.ts +++ b/src/template/analysis.ts @@ -2,6 +2,7 @@ import { Argument, Template, Value } from '.' import { isKeyValuePair } from '../parser/filter-arg' import { PropertyAccessToken, ValueToken } from '../tokens' import { + isGroupedExpressionToken, isNumberToken, isPropertyAccessToken, isQuotedToken, @@ -371,11 +372,31 @@ function * extractValueTokenVariables (token: ValueToken): Generator { if (isRangeToken(token)) { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) + } else if (isGroupedExpressionToken(token)) { + yield * extractGroupedExpressionTokenVariables(token) } else if (isPropertyAccessToken(token)) { yield extractPropertyAccessVariable(token) } } +function * extractGroupedExpressionTokenVariables (token: ValueToken): Generator { + if (!isGroupedExpressionToken(token)) return + + for (const t of token.initial.postfix) { + if (isValueToken(t)) yield * extractValueTokenVariables(t) + } + + for (const filter of token.filters) { + for (const arg of filter.args) { + if (isKeyValuePair(arg) && arg[1]) { + yield * extractValueTokenVariables(arg[1]) + } else if (isValueToken(arg)) { + yield * extractValueTokenVariables(arg) + } + } + } +} + function extractPropertyAccessVariable (token: PropertyAccessToken): Variable { const segments: VariableSegments = [] diff --git a/src/template/output.ts b/src/template/output.ts index cd75ea0b7c..38f4748145 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -12,7 +12,7 @@ export class Output extends TemplateImpl implements Template { value: Value public constructor (token: OutputToken, liquid: Liquid) { super(token) - const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange) + const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange, liquid.options.groupedExpressions) this.value = new Value(tokenizer.readFilteredValue(), liquid) const filters = this.value.filters const outputEscape = liquid.options.outputEscape diff --git a/src/template/value.ts b/src/template/value.ts index 52c8ad7d22..8def204d58 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,11 +1,36 @@ import { Filter } from './filter' import { Expression } from '../render' import { Tokenizer } from '../parser' -import { assert } from '../util' -import type { FilteredValueToken } from '../tokens' +import { assert, isGroupedExpressionToken, isRangeToken, isPropertyAccessToken } from '../util' +import { FilteredValueToken, Token } from '../tokens' import type { Liquid } from '../liquid' import type { Context } from '../context' +function getFilter (liquid: Liquid, name: string) { + const impl = liquid.filters[name] + assert(impl || !liquid.options.strictFilters, () => `undefined filter: ${name}`) + return impl +} + +export function resolveGroupedExpressionFilters (token: Token, liquid: Liquid): void { + if (isGroupedExpressionToken(token)) { + for (const t of token.initial.postfix) { + resolveGroupedExpressionFilters(t, liquid) + } + token.resolvedFilters = token.filters.map(filterToken => + new Filter(filterToken, getFilter(liquid, filterToken.name), liquid) + ) + } + if (isRangeToken(token)) { + resolveGroupedExpressionFilters(token.lhs, liquid) + resolveGroupedExpressionFilters(token.rhs, liquid) + } + if (isPropertyAccessToken(token)) { + if (token.variable) resolveGroupedExpressionFilters(token.variable, liquid) + for (const prop of token.props) resolveGroupedExpressionFilters(prop, liquid) + } +} + export class Value { public readonly filters: Filter[] = [] public readonly initial: Expression @@ -15,10 +40,13 @@ export class Value { */ public constructor (input: string | FilteredValueToken, liquid: Liquid) { const token: FilteredValueToken = typeof input === 'string' - ? new Tokenizer(input, liquid.options.operators).readFilteredValue() + ? new Tokenizer(input, liquid.options.operators, undefined, undefined, liquid.options.groupedExpressions).readFilteredValue() : input this.initial = token.initial - this.filters = token.filters.map(token => new Filter(token, this.getFilter(liquid, token.name), liquid)) + this.filters = token.filters.map(token => new Filter(token, getFilter(liquid, token.name), liquid)) + for (const t of this.initial.postfix) { + resolveGroupedExpressionFilters(t, liquid) + } } public * value (ctx: Context, lenient?: boolean): Generator { @@ -30,10 +58,4 @@ export class Value { } return val } - - private getFilter (liquid: Liquid, name: string) { - const impl = liquid.filters[name] - assert(impl || !liquid.options.strictFilters, () => `undefined filter: ${name}`) - return impl - } } diff --git a/src/tokens/grouped-expression-token.ts b/src/tokens/grouped-expression-token.ts new file mode 100644 index 0000000000..5dd108a436 --- /dev/null +++ b/src/tokens/grouped-expression-token.ts @@ -0,0 +1,19 @@ +import { Token } from './token' +import { FilterToken } from './filter-token' +import { TokenKind } from '../parser' +import { Expression } from '../render' +import type { Filter } from '../template/filter' + +export class GroupedExpressionToken extends Token { + public resolvedFilters?: Filter[] + constructor ( + public initial: Expression, + public filters: FilterToken[], + public input: string, + public begin: number, + public end: number, + public file?: string + ) { + super(TokenKind.GroupedExpression, input, begin, end, file) + } +} diff --git a/src/tokens/index.ts b/src/tokens/index.ts index d1feea6446..9e12538e76 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -16,3 +16,4 @@ export * from './value-token' export * from './liquid-tag-token' export * from './delimited-token' export * from './filtered-value-token' +export * from './grouped-expression-token' diff --git a/src/tokens/liquid-tag-token.ts b/src/tokens/liquid-tag-token.ts index 91f119f218..ef1a9fcef1 100644 --- a/src/tokens/liquid-tag-token.ts +++ b/src/tokens/liquid-tag-token.ts @@ -16,7 +16,7 @@ export class LiquidTagToken extends DelimitedToken { file?: string ) { super(TokenKind.Tag, [begin, end], input, begin, end, false, false, file) - this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange) + this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions) this.name = this.tokenizer.readTagName() this.tokenizer.assert(this.name, 'illegal liquid tag syntax') this.tokenizer.skipBlank() diff --git a/src/tokens/property-access-token.ts b/src/tokens/property-access-token.ts index 8496da24d6..efa4c96f1d 100644 --- a/src/tokens/property-access-token.ts +++ b/src/tokens/property-access-token.ts @@ -5,11 +5,12 @@ import { IdentifierToken } from './identifier-token' import { NumberToken } from './number-token' import { RangeToken } from './range-token' import { QuotedToken } from './quoted-token' +import { GroupedExpressionToken } from './grouped-expression-token' import { TokenKind } from '../parser' export class PropertyAccessToken extends Token { constructor ( - public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | undefined, + public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | GroupedExpressionToken | undefined, public props: (ValueToken | IdentifierToken)[], input: string, begin: number, diff --git a/src/tokens/tag-token.ts b/src/tokens/tag-token.ts index e42268e754..44a2719a1f 100644 --- a/src/tokens/tag-token.ts +++ b/src/tokens/tag-token.ts @@ -17,7 +17,7 @@ export class TagToken extends DelimitedToken { const [valueBegin, valueEnd] = [begin + tagDelimiterLeft.length, end - tagDelimiterRight.length] super(TokenKind.Tag, [valueBegin, valueEnd], input, begin, end, trimTagLeft, trimTagRight, file) - this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange) + this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions) this.name = this.tokenizer.readTagName() this.tokenizer.assert(this.name, `illegal tag syntax, tag name expected`) this.tokenizer.skipBlank() diff --git a/src/tokens/value-token.ts b/src/tokens/value-token.ts index 37e223925b..b48c6191de 100644 --- a/src/tokens/value-token.ts +++ b/src/tokens/value-token.ts @@ -3,5 +3,6 @@ import { LiteralToken } from './literal-token' import { NumberToken } from './number-token' import { QuotedToken } from './quoted-token' import { PropertyAccessToken } from './property-access-token' +import { GroupedExpressionToken } from './grouped-expression-token' -export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken +export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | GroupedExpressionToken diff --git a/src/util/type-guards.ts b/src/util/type-guards.ts index 04fca1c39c..95bd51ff8a 100644 --- a/src/util/type-guards.ts +++ b/src/util/type-guards.ts @@ -1,4 +1,4 @@ -import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken } from '../tokens' +import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken, GroupedExpressionToken } from '../tokens' import { TokenKind } from '../parser' export function isDelimitedToken (val: any): val is DelimitedToken { @@ -45,9 +45,13 @@ export function isRangeToken (val: any): val is RangeToken { return getKind(val) === TokenKind.Range } +export function isGroupedExpressionToken (val: any): val is GroupedExpressionToken { + return getKind(val) === TokenKind.GroupedExpression +} + export function isValueToken (val: any): val is ValueToken { - // valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range - return (getKind(val) & 1667) > 0 + // valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.GroupedExpression + return (getKind(val) & 9859) > 0 } function getKind (val: any) { diff --git a/test/integration/tags/case.spec.ts b/test/integration/tags/case.spec.ts index c5de34bbc8..f92db23994 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -132,4 +132,30 @@ describe('tags/case', function () { TRUE `) }) + describe('parenthesized filter chains', function () { + describe('when enabled', () => { + const ge = new Liquid({ groupedExpressions: true }) + it('should support grouped expression in case value', () => { + const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}' + const html = ge.parseAndRenderSync(src, { status: 'ACTIVE' }) + expect(html).toBe('active') + }) + it('should support grouped expression in when value', () => { + const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}' + const html = ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' }) + expect(html).toBe('match') + }) + }) + describe('when disabled', () => { + const ge = new Liquid({ groupedExpressions: false }) + it('should support grouped expression in case value', () => { + const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}' + expect(() => ge.parseAndRenderSync(src, { status: 'ACTIVE' })).toThrow('invalid range syntax') + }) + it('should support grouped expression in when value', () => { + const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}' + expect(() => ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' })).toThrow('invalid range syntax') + }) + }) + }) }) diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts index 034800d560..fa4addf0b8 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -426,4 +426,21 @@ describe('tags/for', function () { return expect(html).toBe('i-someDrop i-someDrop i-someDrop ') }) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support range with filtered RHS', function () { + const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}' + const html = ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] }) + expect(html).toBe('1 2 3 ') + }) + }) + describe('when disabled', function () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for range with filtered RHS', function () { + const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}' + expect(() => ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })).toThrow('invalid range syntax') + }) + }) + }) }) diff --git a/test/integration/tags/if.spec.ts b/test/integration/tags/if.spec.ts index 2573861e07..feb4ad0bc6 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -169,4 +169,55 @@ describe('tags/if', function () { expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}')) .toThrow(`unexpected elsif after else`) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support (foo | upcase) == "BAR"', async function () { + const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { foo: 'bar' }) + return expect(html).toBe('yes') + }) + it('should support both sides parenthesized', async function () { + const src = '{% if (a | upcase) == (b | upcase) %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { a: 'hi', b: 'hi' }) + return expect(html).toBe('yes') + }) + it('should support with logical operators', async function () { + const src = '{% if (a | upcase) == "FOO" and (b | downcase) == "bar" %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { a: 'foo', b: 'BAR' }) + return expect(html).toBe('yes') + }) + it('should support standalone parenthesized filter via evalValueSync', function () { + const result = ge.evalValueSync('(foo | upcase)', { foo: 'bar' }) + return expect(result).toBe('BAR') + }) + it('should support comparison via evalValueSync', function () { + const result = ge.evalValueSync('(foo | upcase) == "BAR"', { foo: 'bar' }) + return expect(result).toBe(true) + }) + it('should keep range syntax working', function () { + const result = ge.evalValueSync('(1..5)', {}) + return expect(result).toEqual([1, 2, 3, 4, 5]) + }) + it('should support chained filters in condition', async function () { + const src = '{% if (name | downcase | size) > 3 %}long{% else %}short{% endif %}' + const html = await ge.parseAndRender(src, { name: 'Alice' }) + return expect(html).toBe('long') + }) + it('should support nested parenthesized expressions', function () { + const result = ge.evalValueSync('((foo | append: "!") | upcase)', { foo: 'bar' }) + return expect(result).toBe('BAR!') + }) + }) + describe('when disabled', function () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for parenthesized filter in condition', () => { + const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}' + expect(() => ge.parseAndRenderSync(src, { foo: 'bar' })).toThrow('invalid range syntax') + }) + it('should throw for parenthesized filter via evalValueSync', () => { + expect(() => ge.evalValueSync('(foo | upcase)', { foo: 'bar' })).toThrow('invalid range syntax') + }) + }) + }) }) diff --git a/test/integration/tags/unless.spec.ts b/test/integration/tags/unless.spec.ts index 5ca0446a69..406da1d3e4 100644 --- a/test/integration/tags/unless.spec.ts +++ b/test/integration/tags/unless.spec.ts @@ -83,4 +83,21 @@ describe('tags/unless', function () { expect(html).toBe('yes') }) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support grouped expression in unless condition', function () { + const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}' + const html = ge.parseAndRenderSync(src, { content: 'hello' }) + expect(html).toBe('has content') + }) + }) + describe('when disabled', function () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for grouped expression in unless condition', function () { + const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}' + expect(() => ge.parseAndRenderSync(src, { content: 'hello' })).toThrow('invalid range syntax') + }) + }) + }) })