From e7e3aeb81a9a03a865a232a7886e02df4e4c2e94 Mon Sep 17 00:00:00 2001 From: Ihor Panasiuk Date: Mon, 23 Mar 2026 17:27:44 +0100 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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[], From 7f2b3ffeda5bcfeeba2f19b191dbe5d851f5cad0 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Mon, 13 Apr 2026 18:34:40 +0300 Subject: [PATCH 11/16] refactor: reuse FilteredValueToken and fix architectural layering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace GroupedExpressionToken with existing FilteredValueToken to avoid code duplication and fix layering violation where tokens depended on templates (Filter instances). Key changes: - Reuse FilteredValueToken instead of GroupedExpressionToken - Simplify readGroupOrRange() to return FilteredValueToken | RangeToken - Add liquid reference to Context for runtime filter resolution - Build Filter instances at render time in evalFilteredValueToken() - Remove resolveGroupedExpressionFilters() and parse-time resolution - Remove explicit resolution calls from tag constructors This maintains proper architectural layering (tokens → render → templates) with no backward dependencies, as requested in PR review feedback. All 1537 tests pass. --- src/context/context.ts | 10 ++++-- src/index.ts | 2 +- src/liquid.ts | 6 ++-- src/parser/tokenizer.spec.ts | 48 +++++++++++--------------- src/parser/tokenizer.ts | 25 ++++---------- src/render/expression.ts | 16 +++++---- src/tags/case.ts | 9 +++-- src/tags/for.ts | 8 ++--- src/tags/tablerow.ts | 8 ++--- src/template/analysis.ts | 6 ++-- src/template/value.ts | 29 +++------------- src/tokens/grouped-expression-token.ts | 19 ---------- src/tokens/index.ts | 1 - src/tokens/property-access-token.ts | 4 +-- src/tokens/value-token.ts | 4 +-- src/util/type-guards.ts | 15 +++++--- 16 files changed, 81 insertions(+), 129 deletions(-) delete mode 100644 src/tokens/grouped-expression-token.ts diff --git a/src/context/context.ts b/src/context/context.ts index ce396fe36e..2c36f47711 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -31,6 +31,10 @@ export class Context { * The normalized liquid options object */ public opts: NormalizedFullOptions + /** + * Reference to the Liquid instance for filter resolution + */ + public liquid?: any /** * Throw when accessing undefined variable? */ @@ -38,7 +42,7 @@ export class Context { public ownPropertyOnly: boolean; public memoryLimit: Limiter; public renderLimit: Limiter; - public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit }: { [key: string]: Limiter } = {}) { + public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit, liquid }: { memoryLimit?: Limiter, renderLimit?: Limiter, liquid?: any } = {}) { this.sync = !!renderOptions.sync this.opts = opts this.globals = renderOptions.globals ?? opts.globals @@ -47,6 +51,7 @@ export class Context { this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit) this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit)) + this.liquid = liquid } public getRegister (key: string) { return (this.registers[key] = this.registers[key] || {}) @@ -109,7 +114,8 @@ export class Context { strictVariables: this.strictVariables }, { renderLimit: this.renderLimit, - memoryLimit: this.memoryLimit + memoryLimit: this.memoryLimit, + liquid: this.liquid }) } private findScope (key: string | number) { diff --git a/src/index.ts b/src/index.ts index 721c21b508..b6e6abc907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export { Context, Scope } from './context' export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, StaticAnalysis, StaticAnalysisOptions, analyze, analyzeSync, Arguments, PartialScope } from './template' export type { TagRenderReturn } from './template' export { Token, TopLevelToken, TagToken, ValueToken } from './tokens' -export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken } from './tokens' +export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken, FilteredValueToken } from './tokens' export { TokenKind, Tokenizer, ParseStream, Parser } from './parser' export { filters } from './filters' export * from './tags' diff --git a/src/liquid.ts b/src/liquid.ts index 54714bb8c9..9a5ba39747 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -31,7 +31,7 @@ export class Liquid { } public _render (tpl: Template[], scope: Context | object | undefined, renderOptions: RenderOptions): IterableIterator { - const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions) + const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions, { liquid: this }) return this.renderer.renderTemplates(tpl, ctx) } public async render (tpl: Template[], scope?: object, renderOptions?: RenderOptions): Promise { @@ -41,7 +41,7 @@ export class Liquid { return toValueSync(this._render(tpl, scope, { ...renderOptions, sync: true })) } public renderToNodeStream (tpl: Template[], scope?: object, renderOptions: RenderOptions = {}): NodeJS.ReadableStream { - const ctx = new Context(scope, this.options, renderOptions) + const ctx = new Context(scope, this.options, renderOptions, { liquid: this }) return this.renderer.renderTemplatesToNodeStream(tpl, ctx) } @@ -88,7 +88,7 @@ export class Liquid { public _evalValue (str: string, scope?: object | Context): IterableIterator { const value = new Value(str, this) - const ctx = scope instanceof Context ? scope : new Context(scope, this.options) + const ctx = scope instanceof Context ? scope : new Context(scope, this.options, {}, { liquid: this }) return value.value(ctx) } public async evalValue (str: string, scope?: object | Context): Promise { diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index d13ac45f56..109028a49f 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, GroupedExpressionToken } from '../tokens' +import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken, FilteredValueToken } from '../tokens' import { Tokenizer } from './tokenizer' import { defaultOperators } from '../render/operator' import { createTrie } from '../util/operator-trie' @@ -229,29 +229,23 @@ describe('Tokenizer', function () { }) describe('#readRange()', () => { it('should read `(1..3)`', () => { - const result = new Tokenizer('(1..3)').readGroupOrRange() - expect(result).toBeDefined() - expect(result!.type).toBe('range') - const { range } = result as { type: 'range', range: RangeToken } + const range = new Tokenizer('(1..3)').readGroupOrRange() + expect(range).toBeDefined() expect(range).toBeInstanceOf(RangeToken) - 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') + expect(range!.getText()).toEqual('(1..3)') + expect((range as RangeToken).lhs).toBeInstanceOf(NumberToken) + expect((range as RangeToken).lhs.getText()).toBe('1') + expect((range as RangeToken).rhs).toBeInstanceOf(NumberToken) + expect((range as RangeToken).rhs.getText()).toBe('3') }) it('should throw for `(..3)`', () => { expect(() => new Tokenizer('(..3)').readGroupOrRange()).toThrow('unexpected token "..3)", value expected') }) it('should read `(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"])') + const range = new Tokenizer('(a.b..c["..d"])').readGroupOrRange() + expect(range).toBeDefined() + expect(range).toBeInstanceOf(RangeToken) + expect(range!.getText()).toEqual('(a.b..c["..d"])') }) }) describe('#readGroupedExpression()', () => { @@ -260,10 +254,10 @@ describe('Tokenizer', function () { t.groupedExpressions = true return t } - it('should read `(foo | upcase)` as GroupedExpressionToken', () => { + it('should read `(foo | upcase)` as FilteredValueToken', () => { const token = createGrouped('(foo | upcase)').readValue() - expect(token).toBeInstanceOf(GroupedExpressionToken) - const grouped = token as GroupedExpressionToken + expect(token).toBeInstanceOf(FilteredValueToken) + const grouped = token as FilteredValueToken expect(grouped.getText()).toBe('(foo | upcase)') expect(grouped.initial.postfix).toHaveLength(1) expect(grouped.filters).toHaveLength(1) @@ -271,25 +265,25 @@ describe('Tokenizer', function () { }) it('should read `(foo | append: "!")` with filter argument', () => { const token = createGrouped('(foo | append: "!")').readValue() - expect(token).toBeInstanceOf(GroupedExpressionToken) - const grouped = token as GroupedExpressionToken + expect(token).toBeInstanceOf(FilteredValueToken) + const grouped = token as FilteredValueToken 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(token).toBeInstanceOf(FilteredValueToken) + const grouped = token as FilteredValueToken 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) + expect(grouped.initial.postfix[0]).toBeInstanceOf(FilteredValueToken) }) 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[0]).toBeInstanceOf(FilteredValueToken) expect(exp[1]).toBeInstanceOf(OperatorToken) expect(exp[1].getText()).toBe('==') expect(exp[2]).toBeInstanceOf(QuotedToken) diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 3f3de67327..8e5e773702 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, GroupedExpressionToken } from '../tokens' +import { FilteredValueToken, TagToken, HTMLToken, HashToken, QuotedToken, LiquidTagToken, OutputToken, ValueToken, Token, RangeToken, FilterToken, TopLevelToken, PropertyAccessToken, OperatorToken, LiteralToken, IdentifierToken, NumberToken } 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' @@ -311,17 +311,12 @@ export class Tokenizer { return -1 } - readValue (): ValueToken | undefined { + readValue (): ValueToken | FilteredValueToken | undefined { this.skipBlank() const begin = this.p - let variable: ValueToken | undefined = this.readLiteral() || this.readQuoted() + let variable: ValueToken | FilteredValueToken | 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 = this.readGroupOrRange() } variable = variable || this.readNumber() const props = this.readProperties(!variable) @@ -398,7 +393,7 @@ export class Tokenizer { return literal } - readGroupOrRange (): { type: 'range', range: RangeToken } | { type: 'groupedExpression', groupedExpression: GroupedExpressionToken } | undefined { + readGroupOrRange (): FilteredValueToken | RangeToken | undefined { this.skipBlank() const begin = this.p if (this.peek() !== '(') return @@ -411,10 +406,7 @@ export class Tokenizer { 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) - } + return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file) } if (this.groupedExpressions) { @@ -422,10 +414,7 @@ export class Tokenizer { 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) - } + return new FilteredValueToken(expression, filters, this.input, begin, this.p, this.file) } throw this.error('invalid range syntax') diff --git a/src/render/expression.ts b/src/render/expression.ts index 9ae8827a77..d690389614 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -1,8 +1,9 @@ -import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, GroupedExpressionToken } from '../tokens' -import { isRangeToken, isPropertyAccessToken, isGroupedExpressionToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util' +import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, FilteredValueToken } from '../tokens' +import { isRangeToken, isPropertyAccessToken, isFilteredValueToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util' import type { Context } from '../context' import type { UnaryOperatorHandler } from '../render' import { Drop } from '../drop' +import { Filter } from '../template/filter' export class Expression { readonly postfix: Token[] @@ -40,15 +41,18 @@ 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) + if (isFilteredValueToken(token)) return yield evalFilteredValueToken(token, ctx, lenient) } -function * evalGroupedExpressionToken (token: GroupedExpressionToken, ctx: Context, lenient: boolean): IterableIterator { - assert(token.resolvedFilters, 'grouped expression filters not resolved') +function * evalFilteredValueToken (token: FilteredValueToken, ctx: Context, lenient: boolean): IterableIterator { + assert(ctx.liquid, 'FilteredValueToken evaluation requires liquid instance in context') 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!) { + for (const filterToken of token.filters) { + const filterImpl = ctx.liquid.filters[filterToken.name] + assert(filterImpl || !ctx.liquid.options.strictFilters, () => `undefined filter: ${filterToken.name}`) + const filter = new Filter(filterToken, filterImpl, ctx.liquid) val = yield filter.render(val, ctx) } diff --git a/src/tags/case.ts b/src/tags/case.ts index 64c8b9526e..d78357152f 100644 --- a/src/tags/case.ts +++ b/src/tags/case.ts @@ -1,11 +1,11 @@ -import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream } from '..' +import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream, FilteredValueToken } from '..' import { Parser } from '../parser' import { equals } from '../render' -import { Arguments, resolveGroupedExpressionFilters } from '../template' +import { Arguments } from '../template' export default class extends Tag { value: Value - branches: { values: ValueToken[], templates: Template[] }[] = [] + branches: { values: (ValueToken | FilteredValueToken)[], templates: Template[] }[] = [] elseTemplates: Template[] = [] constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) { super(tagToken, remainTokens, liquid) @@ -22,10 +22,9 @@ export default class extends Tag { p = [] - const values: ValueToken[] = [] + const values: (ValueToken | FilteredValueToken)[] = [] while (!token.tokenizer.end()) { const val = token.tokenizer.readValueOrThrow() - 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 8c9bfd620e..1f07850981 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -1,9 +1,8 @@ -import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' -import { GroupedExpressionToken } from '../tokens' +import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..' import { assertEmpty, isValueToken, toEnumerable } from '../util' import { ForloopDrop } from '../drop/forloop-drop' import { Parser } from '../parser' -import { Arguments, resolveGroupedExpressionFilters } from '../template' +import { Arguments } from '../template' const MODIFIERS = ['offset', 'limit', 'reversed'] @@ -11,7 +10,7 @@ type valueOf = T[keyof T] export default class extends Tag { variable: string - collection: ValueToken | GroupedExpressionToken + collection: ValueToken | FilteredValueToken hash: Hash templates: Template[] elseTemplates: Template[] @@ -27,7 +26,6 @@ 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 b4f53c6a18..1d7116d72a 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -1,15 +1,14 @@ import { isValueToken, toEnumerable } from '../util' -import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' -import { GroupedExpressionToken } from '../tokens' +import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..' import { TablerowloopDrop } from '../drop/tablerowloop-drop' import { Parser } from '../parser' -import { Arguments, resolveGroupedExpressionFilters } from '../template' +import { Arguments } from '../template' export default class extends Tag { variable: string args: Hash templates: Template[] - collection: ValueToken | GroupedExpressionToken + collection: ValueToken | FilteredValueToken constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) { super(tagToken, remainTokens, liquid) const variable = this.tokenizer.readIdentifier() @@ -23,7 +22,6 @@ 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 89d00ac307..39b9a10580 100644 --- a/src/template/analysis.ts +++ b/src/template/analysis.ts @@ -2,7 +2,7 @@ import { Argument, Template, Value } from '.' import { isKeyValuePair } from '../parser/filter-arg' import { PropertyAccessToken, ValueToken } from '../tokens' import { - isGroupedExpressionToken, + isFilteredValueToken, isNumberToken, isPropertyAccessToken, isQuotedToken, @@ -372,7 +372,7 @@ function * extractValueTokenVariables (token: ValueToken): Generator { if (isRangeToken(token)) { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) - } else if (isGroupedExpressionToken(token)) { + } else if (isFilteredValueToken(token)) { yield * extractGroupedExpressionTokenVariables(token) } else if (isPropertyAccessToken(token)) { yield extractPropertyAccessVariable(token) @@ -380,7 +380,7 @@ function * extractValueTokenVariables (token: ValueToken): Generator { } function * extractGroupedExpressionTokenVariables (token: ValueToken): Generator { - if (!isGroupedExpressionToken(token)) return + if (!isFilteredValueToken(token)) return for (const t of token.initial.postfix) { if (isValueToken(t)) yield * extractValueTokenVariables(t) diff --git a/src/template/value.ts b/src/template/value.ts index 8def204d58..8f3b2ff470 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,34 +1,16 @@ import { Filter } from './filter' import { Expression } from '../render' import { Tokenizer } from '../parser' -import { assert, isGroupedExpressionToken, isRangeToken, isPropertyAccessToken } from '../util' -import { FilteredValueToken, Token } from '../tokens' +import { FilteredValueToken } 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) + if (!impl && liquid.options.strictFilters) { + throw new Error(`undefined filter: ${name}`) } + return impl } export class Value { @@ -44,9 +26,6 @@ export class Value { : input this.initial = token.initial 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 { diff --git a/src/tokens/grouped-expression-token.ts b/src/tokens/grouped-expression-token.ts deleted file mode 100644 index 5dd108a436..0000000000 --- a/src/tokens/grouped-expression-token.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 9e12538e76..d1feea6446 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -16,4 +16,3 @@ 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/property-access-token.ts b/src/tokens/property-access-token.ts index efa4c96f1d..073c3f8d37 100644 --- a/src/tokens/property-access-token.ts +++ b/src/tokens/property-access-token.ts @@ -5,12 +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 { FilteredValueToken } from './filtered-value-token' import { TokenKind } from '../parser' export class PropertyAccessToken extends Token { constructor ( - public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | GroupedExpressionToken | undefined, + public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | FilteredValueToken | undefined, public props: (ValueToken | IdentifierToken)[], input: string, begin: number, diff --git a/src/tokens/value-token.ts b/src/tokens/value-token.ts index b48c6191de..1fd6751655 100644 --- a/src/tokens/value-token.ts +++ b/src/tokens/value-token.ts @@ -3,6 +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' +import { FilteredValueToken } from './filtered-value-token' -export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | GroupedExpressionToken +export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | FilteredValueToken diff --git a/src/util/type-guards.ts b/src/util/type-guards.ts index 95bd51ff8a..c746ec6350 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, GroupedExpressionToken } from '../tokens' +import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken, FilteredValueToken } from '../tokens' import { TokenKind } from '../parser' export function isDelimitedToken (val: any): val is DelimitedToken { @@ -45,13 +45,18 @@ 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 isFilteredValueToken (val: any): val is FilteredValueToken { + return getKind(val) === TokenKind.FilteredValue +} + +// Deprecated: Use isFilteredValueToken instead +export function isGroupedExpressionToken (val: any): val is FilteredValueToken { + return isFilteredValueToken(val) } export function isValueToken (val: any): val is ValueToken { - // valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.GroupedExpression - return (getKind(val) & 9859) > 0 + // valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.FilteredValue + return (getKind(val) & 5763) > 0 } function getKind (val: any) { From 74e73b7224eca364babae7d3a709106a072bbea2 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Tue, 14 Apr 2026 10:21:42 +0300 Subject: [PATCH 12/16] revert redundant' --- src/tags/case.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tags/case.ts b/src/tags/case.ts index d78357152f..b254d89fa6 100644 --- a/src/tags/case.ts +++ b/src/tags/case.ts @@ -24,8 +24,7 @@ export default class extends Tag { const values: (ValueToken | FilteredValueToken)[] = [] while (!token.tokenizer.end()) { - const val = token.tokenizer.readValueOrThrow() - values.push(val) + values.push(token.tokenizer.readValueOrThrow()) token.tokenizer.skipBlank() if (token.tokenizer.peek() === ',') { token.tokenizer.readTo(',') From 4b620a598b6a89ccc05c27d26f56ab4058f86e00 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Tue, 14 Apr 2026 10:30:56 +0300 Subject: [PATCH 13/16] refactor: make getFilter private and improve code organization --- src/template/value.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/template/value.ts b/src/template/value.ts index 8f3b2ff470..2140c90a52 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,18 +1,11 @@ import { Filter } from './filter' import { Expression } from '../render' import { Tokenizer } from '../parser' -import { FilteredValueToken } from '../tokens' +import { assert } from '../util' +import type { FilteredValueToken } from '../tokens' import type { Liquid } from '../liquid' import type { Context } from '../context' -function getFilter (liquid: Liquid, name: string) { - const impl = liquid.filters[name] - if (!impl && liquid.options.strictFilters) { - throw new Error(`undefined filter: ${name}`) - } - return impl -} - export class Value { public readonly filters: Filter[] = [] public readonly initial: Expression @@ -25,7 +18,7 @@ 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, getFilter(liquid, token.name), liquid)) + this.filters = token.filters.map(token => new Filter(token, this.getFilter(liquid, token.name), liquid)) } public * value (ctx: Context, lenient?: boolean): Generator { @@ -37,4 +30,10 @@ 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 + } } From 2d5b811acfbb907cd2f892c86053a7c54391947e Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Tue, 14 Apr 2026 10:35:05 +0300 Subject: [PATCH 14/16] test: fix test name in case.spec.ts for when disabled block --- test/integration/tags/case.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/tags/case.spec.ts b/test/integration/tags/case.spec.ts index f92db23994..8a9721e5ac 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -148,11 +148,11 @@ describe('tags/case', function () { }) describe('when disabled', () => { const ge = new Liquid({ groupedExpressions: false }) - it('should support grouped expression in case value', () => { + it('should throw error for 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', () => { + it('should throw error for 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') }) From e202158eab7a37439ec76f2897e63cd35bbdb7d8 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Tue, 14 Apr 2026 10:37:01 +0300 Subject: [PATCH 15/16] refactor: no need for Deprecated flag --- src/util/type-guards.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/util/type-guards.ts b/src/util/type-guards.ts index c746ec6350..332fd7a1a4 100644 --- a/src/util/type-guards.ts +++ b/src/util/type-guards.ts @@ -49,11 +49,6 @@ export function isFilteredValueToken (val: any): val is FilteredValueToken { return getKind(val) === TokenKind.FilteredValue } -// Deprecated: Use isFilteredValueToken instead -export function isGroupedExpressionToken (val: any): val is FilteredValueToken { - return isFilteredValueToken(val) -} - export function isValueToken (val: any): val is ValueToken { // valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.FilteredValue return (getKind(val) & 5763) > 0 From 1b9fb805a1e0ec0ffbda99ba306f453e464973d5 Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Tue, 14 Apr 2026 10:40:06 +0300 Subject: [PATCH 16/16] test: fix test name and logic to properly test if tag with nested expressions --- test/integration/tags/if.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/integration/tags/if.spec.ts b/test/integration/tags/if.spec.ts index feb4ad0bc6..21f3ba8e5b 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -204,9 +204,10 @@ describe('tags/if', function () { 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!') + it('should support nested parenthesized expressions in if condition', async function () { + const src = '{% if ((foo | append: "!") | upcase) == "BAR!" %}match{% else %}no match{% endif %}' + const html = await ge.parseAndRender(src, { foo: 'bar' }) + return expect(html).toBe('match') }) }) describe('when disabled', function () {