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-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/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/parser.ts b/src/parser/parser.ts index f61f2323ee..0485f35c38 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -33,7 +33,7 @@ export class Parser { public parse (html: string, filepath?: string): Template[] { html = String(html) this.parseLimit.use(html.length) - const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath) + const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath, undefined, this.liquid.options.groupedExpressions) const tokens = tokenizer.readTopLevelTokens(this.liquid.options) return this.parseTokens(tokens) } diff --git a/src/parser/token-kind.ts b/src/parser/token-kind.ts index f091519ab4..a29017c9d6 100644 --- a/src/parser/token-kind.ts +++ b/src/parser/token-kind.ts @@ -12,5 +12,6 @@ export enum TokenKind { Quoted = 1024, Operator = 2048, FilteredValue = 4096, + GroupedExpression = 8192, Delimited = Tag | Output } diff --git a/src/parser/tokenizer.spec.ts b/src/parser/tokenizer.spec.ts index c8d4f6311f..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 } 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,24 +229,76 @@ describe('Tokenizer', function () { }) describe('#readRange()', () => { it('should read `(1..3)`', () => { - const range = new Tokenizer('(1..3)').readRange() + const range = new Tokenizer('(1..3)').readGroupOrRange() + expect(range).toBeDefined() 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 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)').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() + 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()', () => { + function createGrouped (input: string): Tokenizer { + const t = new Tokenizer(input, defaultOperators) + t.groupedExpressions = true + return t + } + it('should read `(foo | upcase)` as FilteredValueToken', () => { + const token = createGrouped('(foo | upcase)').readValue() + 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) + expect(grouped.filters[0].name).toBe('upcase') + }) + it('should read `(foo | append: "!")` with filter argument', () => { + const token = createGrouped('(foo | append: "!")').readValue() + 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(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(FilteredValueToken) + }) + it('should parse `(a | upcase) == "BAR"` as expression', () => { + const exp = [...createGrouped('(a | upcase) == "BAR"').readExpressionTokens()] + expect(exp).toHaveLength(3) + expect(exp[0]).toBeInstanceOf(FilteredValueToken) + 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') diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 0c2d86df76..8e5e773702 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -9,6 +9,7 @@ import { whiteSpaceCtrl } from './whitespace-ctrl' export class Tokenizer { p: number N: number + public groupedExpressions: boolean private rawBeginAt = -1 private opTrie: Trie private literalTrie: Trie @@ -17,12 +18,14 @@ export class Tokenizer { public input: string, operators: Operators = defaultOptions.operators, public file?: string, - range?: [number, number] + range?: [number, number], + groupedExpressions = false ) { this.p = range ? range[0] : 0 this.N = range ? range[1] : input.length this.opTrie = createTrie(operators) this.literalTrie = createTrie(literalValues) + this.groupedExpressions = groupedExpressions } readExpression () { @@ -80,6 +83,7 @@ export class Tokenizer { readFilter (): FilterToken | null { this.skipBlank() if (this.end()) return null + if (this.peek() === ')') return null this.assert(this.read() === '|', `expected "|" before filter`) const name = this.readIdentifier() if (!name.size()) { @@ -94,9 +98,9 @@ export class Tokenizer { const arg = this.readFilterArg() arg && args.push(arg) this.skipBlank() - this.assert(this.end() || this.peek() === ',' || this.peek() === '|', () => `unexpected character ${this.snapshot()}`) + this.assert(this.end() || this.peek() === ',' || this.peek() === '|' || this.peek() === ')', () => `unexpected character ${this.snapshot()}`) } while (this.peek() === ',') - } else if (this.peek() === '|' || this.end()) { + } else if (this.peek() === '|' || this.peek() === ')' || this.end()) { // do nothing } else { throw this.error('expected ":" after filter name') @@ -307,10 +311,14 @@ export class Tokenizer { return -1 } - readValue (): ValueToken | undefined { + readValue (): ValueToken | FilteredValueToken | undefined { this.skipBlank() const begin = this.p - const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber() + let variable: ValueToken | FilteredValueToken | undefined = this.readLiteral() || this.readQuoted() + if (!variable && this.peek() === '(') { + variable = this.readGroupOrRange() + } + 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 +393,31 @@ export class Tokenizer { return literal } - readRange (): RangeToken | undefined { + readGroupOrRange (): FilteredValueToken | RangeToken | 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 new RangeToken(this.input, begin, this.p, lhs, rhs, this.file) + } + + if (this.groupedExpressions) { + const expression = new Expression((function * () { yield lhs })()) + const filters = this.readFilters() + this.skipBlank() + this.assert(this.read() === ')', 'unbalanced parentheses') + return new FilteredValueToken(expression, filters, this.input, begin, this.p, this.file) + } + + throw this.error('invalid range syntax') } readValueOrThrow (): ValueToken { diff --git a/src/render/expression.ts b/src/render/expression.ts index 62041ae174..d690389614 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -1,8 +1,9 @@ -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, 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,6 +41,22 @@ 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 (isFilteredValueToken(token)) return yield evalFilteredValueToken(token, ctx, lenient) +} + +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 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) + } + + return val } function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator { diff --git a/src/tags/case.ts b/src/tags/case.ts index 63e90c6f4d..b254d89fa6 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 } 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,7 +22,7 @@ export default class extends Tag { p = [] - const values: ValueToken[] = [] + const values: (ValueToken | FilteredValueToken)[] = [] while (!token.tokenizer.end()) { values.push(token.tokenizer.readValueOrThrow()) token.tokenizer.skipBlank() diff --git a/src/tags/for.ts b/src/tags/for.ts index 779eee207b..1f07850981 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -1,4 +1,4 @@ -import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' +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' @@ -10,7 +10,7 @@ type valueOf = T[keyof T] export default class extends Tag { variable: string - collection: ValueToken + collection: ValueToken | FilteredValueToken hash: Hash templates: Template[] elseTemplates: Template[] diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 91f8404451..1d7116d72a 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -1,5 +1,5 @@ import { isValueToken, toEnumerable } from '../util' -import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..' +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 } from '../template' @@ -8,7 +8,7 @@ export default class extends Tag { variable: string args: Hash templates: Template[] - collection: ValueToken + collection: ValueToken | FilteredValueToken constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) { super(tagToken, remainTokens, liquid) const variable = this.tokenizer.readIdentifier() diff --git a/src/template/analysis.ts b/src/template/analysis.ts index 0ad859bd6a..39b9a10580 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 { + isFilteredValueToken, isNumberToken, isPropertyAccessToken, isQuotedToken, @@ -371,11 +372,31 @@ function * extractValueTokenVariables (token: ValueToken): Generator { if (isRangeToken(token)) { yield * extractValueTokenVariables(token.lhs) yield * extractValueTokenVariables(token.rhs) + } else if (isFilteredValueToken(token)) { + yield * extractGroupedExpressionTokenVariables(token) } else if (isPropertyAccessToken(token)) { yield extractPropertyAccessVariable(token) } } +function * extractGroupedExpressionTokenVariables (token: ValueToken): Generator { + if (!isFilteredValueToken(token)) return + + for (const t of token.initial.postfix) { + if (isValueToken(t)) yield * extractValueTokenVariables(t) + } + + for (const filter of token.filters) { + for (const arg of filter.args) { + if (isKeyValuePair(arg) && arg[1]) { + yield * extractValueTokenVariables(arg[1]) + } else if (isValueToken(arg)) { + yield * extractValueTokenVariables(arg) + } + } + } +} + function extractPropertyAccessVariable (token: PropertyAccessToken): Variable { const segments: VariableSegments = [] diff --git a/src/template/output.ts b/src/template/output.ts index cd75ea0b7c..38f4748145 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -12,7 +12,7 @@ export class Output extends TemplateImpl implements Template { value: Value public constructor (token: OutputToken, liquid: Liquid) { super(token) - const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange) + const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange, liquid.options.groupedExpressions) this.value = new Value(tokenizer.readFilteredValue(), liquid) const filters = this.value.filters const outputEscape = liquid.options.outputEscape diff --git a/src/template/value.ts b/src/template/value.ts index 52c8ad7d22..2140c90a52 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -15,7 +15,7 @@ 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)) 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..073c3f8d37 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 { FilteredValueToken } from './filtered-value-token' import { TokenKind } from '../parser' export class PropertyAccessToken extends Token { constructor ( - public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | undefined, + public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | FilteredValueToken | 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..1fd6751655 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 { FilteredValueToken } from './filtered-value-token' -export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken +export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | FilteredValueToken diff --git a/src/util/type-guards.ts b/src/util/type-guards.ts index 04fca1c39c..332fd7a1a4 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, FilteredValueToken } 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 isFilteredValueToken (val: any): val is FilteredValueToken { + return getKind(val) === TokenKind.FilteredValue +} + 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.FilteredValue + return (getKind(val) & 5763) > 0 } function getKind (val: any) { diff --git a/test/integration/tags/case.spec.ts b/test/integration/tags/case.spec.ts index c5de34bbc8..8a9721e5ac 100644 --- a/test/integration/tags/case.spec.ts +++ b/test/integration/tags/case.spec.ts @@ -132,4 +132,30 @@ describe('tags/case', function () { TRUE `) }) + describe('parenthesized filter chains', function () { + describe('when enabled', () => { + const ge = new Liquid({ groupedExpressions: true }) + it('should support grouped expression in case value', () => { + const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}' + const html = ge.parseAndRenderSync(src, { status: 'ACTIVE' }) + expect(html).toBe('active') + }) + it('should support grouped expression in when value', () => { + const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}' + const html = ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' }) + expect(html).toBe('match') + }) + }) + describe('when disabled', () => { + const ge = new Liquid({ groupedExpressions: false }) + it('should 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 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') + }) + }) + }) }) diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts index 034800d560..fa4addf0b8 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -426,4 +426,21 @@ describe('tags/for', function () { return expect(html).toBe('i-someDrop i-someDrop i-someDrop ') }) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support range with filtered RHS', function () { + const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}' + const html = ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] }) + expect(html).toBe('1 2 3 ') + }) + }) + describe('when disabled', function () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for range with filtered RHS', function () { + const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}' + expect(() => ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })).toThrow('invalid range syntax') + }) + }) + }) }) diff --git a/test/integration/tags/if.spec.ts b/test/integration/tags/if.spec.ts index 2573861e07..21f3ba8e5b 100644 --- a/test/integration/tags/if.spec.ts +++ b/test/integration/tags/if.spec.ts @@ -169,4 +169,56 @@ describe('tags/if', function () { expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}')) .toThrow(`unexpected elsif after else`) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support (foo | upcase) == "BAR"', async function () { + const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { foo: 'bar' }) + return expect(html).toBe('yes') + }) + it('should support both sides parenthesized', async function () { + const src = '{% if (a | upcase) == (b | upcase) %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { a: 'hi', b: 'hi' }) + return expect(html).toBe('yes') + }) + it('should support with logical operators', async function () { + const src = '{% if (a | upcase) == "FOO" and (b | downcase) == "bar" %}yes{% else %}no{% endif %}' + const html = await ge.parseAndRender(src, { a: 'foo', b: 'BAR' }) + return expect(html).toBe('yes') + }) + it('should support standalone parenthesized filter via evalValueSync', function () { + const result = ge.evalValueSync('(foo | upcase)', { foo: 'bar' }) + return expect(result).toBe('BAR') + }) + it('should support comparison via evalValueSync', function () { + const result = ge.evalValueSync('(foo | upcase) == "BAR"', { foo: 'bar' }) + return expect(result).toBe(true) + }) + it('should keep range syntax working', function () { + const result = ge.evalValueSync('(1..5)', {}) + return expect(result).toEqual([1, 2, 3, 4, 5]) + }) + it('should support chained filters in condition', async function () { + const src = '{% if (name | downcase | size) > 3 %}long{% else %}short{% endif %}' + const html = await ge.parseAndRender(src, { name: 'Alice' }) + return expect(html).toBe('long') + }) + it('should support nested parenthesized expressions 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 () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for parenthesized filter in condition', () => { + const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}' + expect(() => ge.parseAndRenderSync(src, { foo: 'bar' })).toThrow('invalid range syntax') + }) + it('should throw for parenthesized filter via evalValueSync', () => { + expect(() => ge.evalValueSync('(foo | upcase)', { foo: 'bar' })).toThrow('invalid range syntax') + }) + }) + }) }) diff --git a/test/integration/tags/unless.spec.ts b/test/integration/tags/unless.spec.ts index 5ca0446a69..406da1d3e4 100644 --- a/test/integration/tags/unless.spec.ts +++ b/test/integration/tags/unless.spec.ts @@ -83,4 +83,21 @@ describe('tags/unless', function () { expect(html).toBe('yes') }) }) + describe('parenthesized filter chains', function () { + describe('when enabled', function () { + const ge = new Liquid({ groupedExpressions: true }) + it('should support grouped expression in unless condition', function () { + const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}' + const html = ge.parseAndRenderSync(src, { content: 'hello' }) + expect(html).toBe('has content') + }) + }) + describe('when disabled', function () { + const ge = new Liquid({ groupedExpressions: false }) + it('should throw for grouped expression in unless condition', function () { + const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}' + expect(() => ge.parseAndRenderSync(src, { content: 'hello' })).toThrow('invalid range syntax') + }) + }) + }) })