From e7e3aeb81a9a03a865a232a7886e02df4e4c2e94 Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Mon, 23 Mar 2026 17:27:44 +0100 Subject: [PATCH 01/10] Add support of inner expressions enclosed by parentheses --- src/liquid-options.ts | 4 ++ src/parser/parser.ts | 2 +- src/parser/token-kind.ts | 1 + src/parser/tokenizer.spec.ts | 54 ++++++++++++++++- src/parser/tokenizer.ts | 81 +++++++++++++++++++++++++- src/render/expression.ts | 10 +++- src/tags/case.ts | 6 +- src/tags/for.ts | 3 +- src/template/analysis.ts | 11 ++++ src/template/output.ts | 2 +- src/template/value.ts | 27 ++++++++- src/tokens/grouped-expression-token.ts | 18 ++++++ src/tokens/index.ts | 1 + src/tokens/liquid-tag-token.ts | 2 +- src/tokens/property-access-token.ts | 3 +- src/tokens/tag-token.ts | 2 +- src/tokens/value-token.ts | 3 +- src/util/type-guards.ts | 10 +++- test/integration/tags/case.spec.ts | 13 +++++ test/integration/tags/for.spec.ts | 8 +++ test/integration/tags/if.spec.ts | 39 +++++++++++++ test/integration/tags/unless.spec.ts | 8 +++ 22 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 src/tokens/grouped-expression-token.ts 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 2c02dc4073..ad1c05d806 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -28,7 +28,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..d4ca82c7c1 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' @@ -247,6 +247,58 @@ describe('Tokenizer', function () { expect(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 return undefined for unclosed parens', () => { + const token = createGrouped('(foo | upcase').readValue() + expect(token).toBeUndefined() + }) + it('should fall back to readRange when flag is off', () => { + expect(() => new Tokenizer('(foo | upcase)', defaultOperators).readValue()).toThrow('invalid range syntax') + }) + }) describe('#readFilter()', () => { it('should read a simple filter', function () { const tokenizer = new Tokenizer('| plus') diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 0c2d86df76..f366ce639a 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 () { @@ -310,7 +313,15 @@ 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() === '(') { + if (this.groupedExpressions && !this.looksLikeRange()) { + variable = this.readGroupedExpression() + } else { + variable = this.readRange() + } + } + variable = variable || this.readNumber() const props = this.readProperties(!variable) if (!props.length) return variable return new PropertyAccessToken(variable, props, this.input, begin, this.p) @@ -420,6 +431,70 @@ export class Tokenizer { return new QuotedToken(this.input, begin, this.p, this.file) } + readGroupedExpression (): GroupedExpressionToken | undefined { + this.skipBlank() + if (this.peek() !== '(') return + const begin = this.p + ++this.p + const closeParen = this.findMatchingParen() + if (closeParen === -1) { + this.p = begin + return + } + const savedN = this.N + this.N = closeParen + const fvt = this.readFilteredValue() + this.N = savedN + this.p = closeParen + 1 + return new GroupedExpressionToken(fvt.initial, fvt.filters, this.input, begin, this.p, this.file) + } + + private findMatchingParen (): number { + let depth = 1 + let i = this.p + while (i < this.N && depth > 0) { + const ch = this.input[i] + if (ch === '(') { + depth++ + } else if (ch === ')') { + depth-- + if (depth === 0) return i + } else if (ch === '"' || ch === "'") { + const quote = ch + i++ + while (i < this.N && this.input[i] !== quote) { + if (this.input[i] === '\\') i++ + i++ + } + } + i++ + } + return -1 + } + + private looksLikeRange (): boolean { + let i = this.p + 1 + let depth = 1 + while (i < this.N && depth > 0) { + const ch = this.input[i] + if (ch === '(') { + depth++ + } else if (ch === ')') { + depth-- + } else if (ch === '"' || ch === "'") { + i++ + while (i < this.N && this.input[i] !== ch) { + if (this.input[i] === '\\') i++ + i++ + } + } else if (depth === 1 && ch === '.' && this.input[i + 1] === '.') { + return true + } + i++ + } + return false + } + * readFileNameTemplate (options: NormalizedFullOptions): IterableIterator { const { outputDelimiterLeft } = options const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft] diff --git a/src/render/expression.ts b/src/render/expression.ts index 62041ae174..68f9bfcf12 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,12 @@ 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.resolvedValue, 'grouped expression not resolved') + return yield token.resolvedValue!.value(ctx, lenient) } function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator { diff --git a/src/tags/case.ts b/src/tags/case.ts index 63e90c6f4d..4790700e3f 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, resolveGroupedExpressions } 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() + resolveGroupedExpressions(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..e8a3acca02 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -2,7 +2,7 @@ import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelTo import { assertEmpty, isValueToken, toEnumerable } from '../util' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' -import { Arguments } from '../template' +import { Arguments, resolveGroupedExpressions } from '../template' const MODIFIERS = ['offset', 'limit', 'reversed'] @@ -26,6 +26,7 @@ export default class extends Tag { this.variable = variable.content this.collection = collection + resolveGroupedExpressions(this.collection, liquid) this.hash = new Hash(this.tokenizer, liquid.options.keyValueSeparator) this.templates = [] this.elseTemplates = [] diff --git a/src/template/analysis.ts b/src/template/analysis.ts index 0ad859bd6a..5c07646efc 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,6 +372,16 @@ function * extractValueTokenVariables (token: ValueToken): Generator { if (isRangeToken(token)) { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) + } else if (isGroupedExpressionToken(token)) { + 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) + } + } } else if (isPropertyAccessToken(token)) { yield extractPropertyAccessVariable(token) } 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..8850058ddb 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,11 +1,29 @@ 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' +export function resolveGroupedExpressions (token: Token, liquid: Liquid): void { + if (isGroupedExpressionToken(token)) { + const fvt = new FilteredValueToken( + token.initial, token.filters, + token.input, token.begin, token.end, token.file + ) + token.resolvedValue = new Value(fvt, liquid) + } + if (isRangeToken(token)) { + resolveGroupedExpressions(token.lhs, liquid) + resolveGroupedExpressions(token.rhs, liquid) + } + if (isPropertyAccessToken(token)) { + if (token.variable) resolveGroupedExpressions(token.variable, liquid) + for (const prop of token.props) resolveGroupedExpressions(prop, liquid) + } +} + export class Value { public readonly filters: Filter[] = [] public readonly initial: Expression @@ -15,10 +33,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)) + for (const t of this.initial.postfix) { + resolveGroupedExpressions(t, liquid) + } } public * value (ctx: Context, lenient?: boolean): Generator { diff --git a/src/tokens/grouped-expression-token.ts b/src/tokens/grouped-expression-token.ts new file mode 100644 index 0000000000..5b3a97f497 --- /dev/null +++ b/src/tokens/grouped-expression-token.ts @@ -0,0 +1,18 @@ +import { Token } from './token' +import { FilterToken } from './filter-token' +import { TokenKind } from '../parser' +import { Expression } from '../render' + +export class GroupedExpressionToken extends Token { + public resolvedValue?: { value (ctx: any, lenient?: boolean): Generator } + 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..67d02672b1 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -132,4 +132,17 @@ describe('tags/case', function () { TRUE `) }) + describe('parenthesized filter chains', function () { + 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') + }) + }) }) diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts index 034800d560..0601a080d9 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -426,4 +426,12 @@ describe('tags/for', function () { return expect(html).toBe('i-someDrop i-someDrop i-someDrop ') }) }) + describe('parenthesized filter chains', 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 ') + }) + }) }) diff --git a/test/integration/tags/if.spec.ts b/test/integration/tags/if.spec.ts index 2573861e07..3fab1e0704 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -169,4 +169,43 @@ describe('tags/if', function () { expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}')) .toThrow(`unexpected elsif after else`) }) + describe('parenthesized filter chains', 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!') + }) + }) }) diff --git a/test/integration/tags/unless.spec.ts b/test/integration/tags/unless.spec.ts index 5ca0446a69..a72e767f8f 100644 --- a/test/integration/tags/unless.spec.ts +++ b/test/integration/tags/unless.spec.ts @@ -83,4 +83,12 @@ describe('tags/unless', function () { expect(html).toBe('yes') }) }) + describe('parenthesized filter chains', 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') + }) + }) }) From 75314d023bd4d0b692099d82e835e7837a46a54e Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Mon, 23 Mar 2026 17:27:44 +0100 Subject: [PATCH 02/10] Add support of inner expressions enclosed by parentheses Made-with: Cursor --- src/liquid-options.ts | 4 ++ src/parser/parser.ts | 2 +- src/parser/token-kind.ts | 1 + src/parser/tokenizer.spec.ts | 82 ++++++++++++++++++++++---- src/parser/tokenizer.ts | 76 +++++++++++++++++++++--- src/render/expression.ts | 10 +++- src/tags/case.ts | 6 +- src/tags/for.ts | 3 +- src/template/analysis.ts | 11 ++++ src/template/output.ts | 2 +- src/template/value.ts | 27 ++++++++- src/tokens/grouped-expression-token.ts | 18 ++++++ src/tokens/index.ts | 1 + src/tokens/liquid-tag-token.ts | 2 +- src/tokens/property-access-token.ts | 3 +- src/tokens/tag-token.ts | 2 +- src/tokens/value-token.ts | 3 +- src/util/type-guards.ts | 10 +++- test/integration/tags/case.spec.ts | 13 ++++ test/integration/tags/for.spec.ts | 8 +++ test/integration/tags/if.spec.ts | 39 ++++++++++++ test/integration/tags/unless.spec.ts | 8 +++ 22 files changed, 293 insertions(+), 38 deletions(-) create mode 100644 src/tokens/grouped-expression-token.ts 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 2c02dc4073..ad1c05d806 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -28,7 +28,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..2e1e791a2d 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 () { @@ -310,7 +313,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 +397,41 @@ 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 closeParen = this.findMatchingParen() + this.assert(closeParen !== -1, 'unbalanced parentheses') + const savedN = this.N + this.N = closeParen + const filters = this.readFilters() + this.N = savedN + this.p = closeParen + 1 + return { + type: 'groupedExpression', + groupedExpression: new GroupedExpressionToken(expression, filters, this.input, begin, this.p, this.file) + } + } + + throw this.error('invalid range syntax') } readValueOrThrow (): ValueToken { @@ -420,6 +455,29 @@ export class Tokenizer { return new QuotedToken(this.input, begin, this.p, this.file) } + private findMatchingParen (): number { + let depth = 1 + let i = this.p + while (i < this.N && depth > 0) { + const ch = this.input[i] + if (ch === '(') { + depth++ + } else if (ch === ')') { + depth-- + if (depth === 0) return i + } else if (ch === '"' || ch === "'") { + const quote = ch + i++ + while (i < this.N && this.input[i] !== quote) { + if (this.input[i] === '\\') i++ + i++ + } + } + i++ + } + return -1 + } + * readFileNameTemplate (options: NormalizedFullOptions): IterableIterator { const { outputDelimiterLeft } = options const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft] diff --git a/src/render/expression.ts b/src/render/expression.ts index 62041ae174..68f9bfcf12 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,12 @@ 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.resolvedValue, 'grouped expression not resolved') + return yield token.resolvedValue!.value(ctx, lenient) } function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator { diff --git a/src/tags/case.ts b/src/tags/case.ts index 63e90c6f4d..4790700e3f 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, resolveGroupedExpressions } 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() + resolveGroupedExpressions(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..e8a3acca02 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -2,7 +2,7 @@ import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelTo import { assertEmpty, isValueToken, toEnumerable } from '../util' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' -import { Arguments } from '../template' +import { Arguments, resolveGroupedExpressions } from '../template' const MODIFIERS = ['offset', 'limit', 'reversed'] @@ -26,6 +26,7 @@ export default class extends Tag { this.variable = variable.content this.collection = collection + resolveGroupedExpressions(this.collection, liquid) this.hash = new Hash(this.tokenizer, liquid.options.keyValueSeparator) this.templates = [] this.elseTemplates = [] diff --git a/src/template/analysis.ts b/src/template/analysis.ts index 0ad859bd6a..5c07646efc 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,6 +372,16 @@ function * extractValueTokenVariables (token: ValueToken): Generator { if (isRangeToken(token)) { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) + } else if (isGroupedExpressionToken(token)) { + 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) + } + } } else if (isPropertyAccessToken(token)) { yield extractPropertyAccessVariable(token) } 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..8850058ddb 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,11 +1,29 @@ 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' +export function resolveGroupedExpressions (token: Token, liquid: Liquid): void { + if (isGroupedExpressionToken(token)) { + const fvt = new FilteredValueToken( + token.initial, token.filters, + token.input, token.begin, token.end, token.file + ) + token.resolvedValue = new Value(fvt, liquid) + } + if (isRangeToken(token)) { + resolveGroupedExpressions(token.lhs, liquid) + resolveGroupedExpressions(token.rhs, liquid) + } + if (isPropertyAccessToken(token)) { + if (token.variable) resolveGroupedExpressions(token.variable, liquid) + for (const prop of token.props) resolveGroupedExpressions(prop, liquid) + } +} + export class Value { public readonly filters: Filter[] = [] public readonly initial: Expression @@ -15,10 +33,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)) + for (const t of this.initial.postfix) { + resolveGroupedExpressions(t, liquid) + } } public * value (ctx: Context, lenient?: boolean): Generator { diff --git a/src/tokens/grouped-expression-token.ts b/src/tokens/grouped-expression-token.ts new file mode 100644 index 0000000000..5b3a97f497 --- /dev/null +++ b/src/tokens/grouped-expression-token.ts @@ -0,0 +1,18 @@ +import { Token } from './token' +import { FilterToken } from './filter-token' +import { TokenKind } from '../parser' +import { Expression } from '../render' + +export class GroupedExpressionToken extends Token { + public resolvedValue?: { value (ctx: any, lenient?: boolean): Generator } + 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..67d02672b1 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -132,4 +132,17 @@ describe('tags/case', function () { TRUE `) }) + describe('parenthesized filter chains', function () { + 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') + }) + }) }) diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts index 034800d560..0601a080d9 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -426,4 +426,12 @@ describe('tags/for', function () { return expect(html).toBe('i-someDrop i-someDrop i-someDrop ') }) }) + describe('parenthesized filter chains', 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 ') + }) + }) }) diff --git a/test/integration/tags/if.spec.ts b/test/integration/tags/if.spec.ts index 2573861e07..3fab1e0704 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -169,4 +169,43 @@ describe('tags/if', function () { expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}')) .toThrow(`unexpected elsif after else`) }) + describe('parenthesized filter chains', 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!') + }) + }) }) diff --git a/test/integration/tags/unless.spec.ts b/test/integration/tags/unless.spec.ts index 5ca0446a69..a72e767f8f 100644 --- a/test/integration/tags/unless.spec.ts +++ b/test/integration/tags/unless.spec.ts @@ -83,4 +83,12 @@ describe('tags/unless', function () { expect(html).toBe('yes') }) }) + describe('parenthesized filter chains', 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') + }) + }) }) From a84df579548e921370319135478a49ff95ff0125 Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Fri, 3 Apr 2026 16:48:11 +0200 Subject: [PATCH 03/10] simplify implementation --- src/parser/tokenizer.spec.ts | 5 ++--- src/parser/tokenizer.ts | 37 ++++++------------------------------ 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index a61a65c632..7cce05db86 100644 --- a/src/parser/tokenizer.spec.ts +++ b/src/parser/tokenizer.spec.ts @@ -349,9 +349,8 @@ describe('Tokenizer', function () { const token = createGrouped('(1..3)').readValue() expect(token).toBeInstanceOf(RangeToken) }) - it('should return undefined for unclosed parens', () => { - const token = createGrouped('(foo | upcase').readValue() - expect(token).toBeUndefined() + 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') diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 2e1e791a2d..183ff09ef4 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -83,7 +83,8 @@ export class Tokenizer { readFilter (): FilterToken | null { this.skipBlank() if (this.end()) return null - this.assert(this.read() === '|', `expected "|" before filter`) + if (this.peek() !== '|') return null + ++this.p const name = this.readIdentifier() if (!name.size()) { this.assert(this.end(), `expected filter name`) @@ -97,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') @@ -418,13 +419,9 @@ export class Tokenizer { if (this.groupedExpressions) { const expression = new Expression((function * () { yield lhs })()) - const closeParen = this.findMatchingParen() - this.assert(closeParen !== -1, 'unbalanced parentheses') - const savedN = this.N - this.N = closeParen const filters = this.readFilters() - this.N = savedN - this.p = closeParen + 1 + this.skipBlank() + this.assert(this.read() === ')', 'unbalanced parentheses') return { type: 'groupedExpression', groupedExpression: new GroupedExpressionToken(expression, filters, this.input, begin, this.p, this.file) @@ -455,28 +452,6 @@ export class Tokenizer { return new QuotedToken(this.input, begin, this.p, this.file) } - private findMatchingParen (): number { - let depth = 1 - let i = this.p - while (i < this.N && depth > 0) { - const ch = this.input[i] - if (ch === '(') { - depth++ - } else if (ch === ')') { - depth-- - if (depth === 0) return i - } else if (ch === '"' || ch === "'") { - const quote = ch - i++ - while (i < this.N && this.input[i] !== quote) { - if (this.input[i] === '\\') i++ - i++ - } - } - i++ - } - return -1 - } * readFileNameTemplate (options: NormalizedFullOptions): IterableIterator { const { outputDelimiterLeft } = options From 4b5296abd7796c269b94f7415c330e171a0f506d Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Fri, 3 Apr 2026 16:50:37 +0200 Subject: [PATCH 04/10] fix lint --- src/parser/tokenizer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 183ff09ef4..34338ff33a 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -452,7 +452,6 @@ export class Tokenizer { return new QuotedToken(this.input, begin, this.p, this.file) } - * readFileNameTemplate (options: NormalizedFullOptions): IterableIterator { const { outputDelimiterLeft } = options const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft] From 0bedce9fdb00272ae18fcb9862001666f54478a8 Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Fri, 3 Apr 2026 17:01:58 +0200 Subject: [PATCH 05/10] fix test --- src/parser/tokenizer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 34338ff33a..3f3de67327 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -83,8 +83,8 @@ export class Tokenizer { readFilter (): FilterToken | null { this.skipBlank() if (this.end()) return null - if (this.peek() !== '|') return null - ++this.p + if (this.peek() === ')') return null + this.assert(this.read() === '|', `expected "|" before filter`) const name = this.readIdentifier() if (!name.size()) { this.assert(this.end(), `expected filter name`) From f45c4f1a5575f3767c71de2938164edbc5586339 Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Fri, 3 Apr 2026 17:35:45 +0200 Subject: [PATCH 06/10] Enhance tests for parenthesized filter chains in Liquid tags. Added scenarios for enabled and disabled grouped expressions in case, for, if, unless tags, ensuring proper handling of expressions and error throwing for invalid syntax. --- test/integration/tags/case.spec.ts | 31 +++++++--- test/integration/tags/for.spec.ts | 19 +++++-- test/integration/tags/if.spec.ts | 84 ++++++++++++++++------------ test/integration/tags/unless.spec.ts | 19 +++++-- 4 files changed, 98 insertions(+), 55 deletions(-) diff --git a/test/integration/tags/case.spec.ts b/test/integration/tags/case.spec.ts index 67d02672b1..f92db23994 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -133,16 +133,29 @@ describe('tags/case', function () { `) }) describe('parenthesized filter chains', function () { - 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') + 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') + }) }) - 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 0601a080d9..fa4addf0b8 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -427,11 +427,20 @@ describe('tags/for', function () { }) }) describe('parenthesized filter chains', 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 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 3fab1e0704..feb4ad0bc6 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -170,42 +170,54 @@ describe('tags/if', function () { .toThrow(`unexpected elsif after else`) }) describe('parenthesized filter chains', 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 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 a72e767f8f..406da1d3e4 100644 --- a/test/integration/tags/unless.spec.ts +++ b/test/integration/tags/unless.spec.ts @@ -84,11 +84,20 @@ describe('tags/unless', function () { }) }) describe('parenthesized filter chains', 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 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') + }) }) }) }) From 2cc74bfd037909d4167bd92ec4298cecf7649356 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Sun, 12 Apr 2026 17:30:35 +0300 Subject: [PATCH 07/10] test: remove duplicate readGroupedExpression test block The readGroupedExpression() test suite was duplicated twice in the spec file. Removed the duplicate block to avoid redundant test execution. --- src/parser/tokenizer.spec.ts | 51 ------------------------------------ 1 file changed, 51 deletions(-) diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index 7cce05db86..d13ac45f56 100644 --- a/src/parser/tokenizer.spec.ts +++ b/src/parser/tokenizer.spec.ts @@ -305,57 +305,6 @@ describe('Tokenizer', function () { expect(() => new Tokenizer('(foo | upcase)', defaultOperators).readValue()).toThrow('invalid range syntax') }) }) - 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()', () => { it('should read a simple filter', function () { const tokenizer = new Tokenizer('| plus') From e1afb68aac8fa7ec413dcab812b48acb25fb891f Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Sun, 12 Apr 2026 17:42:15 +0300 Subject: [PATCH 08/10] refactor: extract extractGroupedExpressionTokenVariables helper Extract inline grouped expression variable extraction logic into a dedicated function for consistency with other extractors (extractFilteredValueVariables, extractPropertyAccessVariable). This addresses PR #863 comment 7 - improves code organization and maintainability. --- src/template/analysis.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/template/analysis.ts b/src/template/analysis.ts index 5c07646efc..89d00ac307 100644 --- a/src/template/analysis.ts +++ b/src/template/analysis.ts @@ -373,20 +373,30 @@ function * extractValueTokenVariables (token: ValueToken): Generator { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) } else if (isGroupedExpressionToken(token)) { - 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) - } - } + 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 = [] From 60a6d0ac535d6cf348d7ce785e18db213febd077 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Sun, 12 Apr 2026 17:48:03 +0300 Subject: [PATCH 09/10] refactor(types): explicit type for collection in for tag collection: ValueToken | GroupedExpressionToken Addresses PR #863 comment 5. --- src/tags/for.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tags/for.ts b/src/tags/for.ts index e8a3acca02..a48db78a0c 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -1,4 +1,5 @@ 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' @@ -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[] From c4b4b6aa7171073d823f6bc46a70fdfee2299519 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Mon, 13 Apr 2026 10:56:11 +0300 Subject: [PATCH 10/10] refactor: evaluate grouped expressions at render time with resolvedFilters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review comments 4, 6, 8, 9 - moves grouped expression evaluation from parse-time resolution to render-time lazy evaluation following the generator-based async/sync duality pattern used throughout liquidjs. Key changes: - Replace resolvedValue (Value instance) with resolvedFilters (Filter[]) - Rename resolveGroupedExpressions() to resolveGroupedExpressionFilters() - Move evaluation logic to evalGroupedExpressionToken() at render time - Build Filter instances at parse time (carry liquid reference for render) - Evaluate expression and apply filters lazily via generators - Add support for tablerow tag with grouped expressions - Remove duplicate getFilter() method in Value class Maintains proper layering (tokens → render → templates) and consistency with Value.value() pattern. Filter resolution still happens at parse time since it requires liquid.filters access, but actual evaluation is deferred to render time. Tags that store raw ValueToken (for, case when-values, tablerow) still need explicit resolveGroupedExpressionFilters() calls. Tags that wrap with new Value() get automatic recursive resolution via Value constructor. --- src/render/expression.ts | 11 ++++++-- src/tags/case.ts | 4 +-- src/tags/for.ts | 4 +-- src/tags/tablerow.ts | 6 +++-- src/template/value.ts | 35 +++++++++++++------------- src/tokens/grouped-expression-token.ts | 3 ++- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/render/expression.ts b/src/render/expression.ts index 68f9bfcf12..9ae8827a77 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -44,8 +44,15 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f } function * evalGroupedExpressionToken (token: GroupedExpressionToken, ctx: Context, lenient: boolean): IterableIterator { - assert(token.resolvedValue, 'grouped expression not resolved') - return yield token.resolvedValue!.value(ctx, lenient) + 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 4790700e3f..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, resolveGroupedExpressions } from '../template' +import { Arguments, resolveGroupedExpressionFilters } from '../template' export default class extends Tag { value: Value @@ -25,7 +25,7 @@ export default class extends Tag { const values: ValueToken[] = [] while (!token.tokenizer.end()) { const val = token.tokenizer.readValueOrThrow() - resolveGroupedExpressions(val, liquid) + resolveGroupedExpressionFilters(val, liquid) values.push(val) token.tokenizer.skipBlank() if (token.tokenizer.peek() === ',') { diff --git a/src/tags/for.ts b/src/tags/for.ts index a48db78a0c..8c9bfd620e 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -3,7 +3,7 @@ import { GroupedExpressionToken } from '../tokens' import { assertEmpty, isValueToken, toEnumerable } from '../util' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' -import { Arguments, resolveGroupedExpressions } from '../template' +import { Arguments, resolveGroupedExpressionFilters } from '../template' const MODIFIERS = ['offset', 'limit', 'reversed'] @@ -27,7 +27,7 @@ export default class extends Tag { this.variable = variable.content this.collection = collection - resolveGroupedExpressions(this.collection, liquid) + 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/value.ts b/src/template/value.ts index 8850058ddb..8def204d58 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -6,21 +6,28 @@ import { FilteredValueToken, Token } from '../tokens' import type { Liquid } from '../liquid' import type { Context } from '../context' -export function resolveGroupedExpressions (token: Token, liquid: Liquid): void { +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)) { - const fvt = new FilteredValueToken( - token.initial, token.filters, - token.input, token.begin, token.end, token.file + for (const t of token.initial.postfix) { + resolveGroupedExpressionFilters(t, liquid) + } + token.resolvedFilters = token.filters.map(filterToken => + new Filter(filterToken, getFilter(liquid, filterToken.name), liquid) ) - token.resolvedValue = new Value(fvt, liquid) } if (isRangeToken(token)) { - resolveGroupedExpressions(token.lhs, liquid) - resolveGroupedExpressions(token.rhs, liquid) + resolveGroupedExpressionFilters(token.lhs, liquid) + resolveGroupedExpressionFilters(token.rhs, liquid) } if (isPropertyAccessToken(token)) { - if (token.variable) resolveGroupedExpressions(token.variable, liquid) - for (const prop of token.props) resolveGroupedExpressions(prop, liquid) + if (token.variable) resolveGroupedExpressionFilters(token.variable, liquid) + for (const prop of token.props) resolveGroupedExpressionFilters(prop, liquid) } } @@ -36,9 +43,9 @@ export class Value { ? 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) { - resolveGroupedExpressions(t, liquid) + resolveGroupedExpressionFilters(t, liquid) } } @@ -51,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 index 5b3a97f497..5dd108a436 100644 --- a/src/tokens/grouped-expression-token.ts +++ b/src/tokens/grouped-expression-token.ts @@ -2,9 +2,10 @@ 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 resolvedValue?: { value (ctx: any, lenient?: boolean): Generator } + public resolvedFilters?: Filter[] constructor ( public initial: Expression, public filters: FilterToken[],