Skip to content
4 changes: 4 additions & 0 deletions src/liquid-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -159,6 +161,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
globals: object;
keepOutputType: boolean;
operators: Operators;
groupedExpressions: boolean;
parseLimit: number;
renderLimit: number;
memoryLimit: number;
Expand Down Expand Up @@ -195,6 +198,7 @@ export const defaultOptions: NormalizedFullOptions = {
globals: {},
keepOutputType: false,
operators: defaultOperators,
groupedExpressions: false,
memoryLimit: Infinity,
parseLimit: Infinity,
renderLimit: Infinity
Expand Down
2 changes: 1 addition & 1 deletion src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions src/parser/token-kind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export enum TokenKind {
Quoted = 1024,
Operator = 2048,
FilteredValue = 4096,
GroupedExpression = 8192,
Delimited = Tag | Output
}
54 changes: 53 additions & 1 deletion src/parser/tokenizer.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
Expand Down
81 changes: 78 additions & 3 deletions src/parser/tokenizer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<OperatorHandler>
private literalTrie: Trie<LiteralValue>
Expand All @@ -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 () {
Expand Down Expand Up @@ -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()
Comment thread
skynetigor marked this conversation as resolved.
Outdated
}
}
variable = variable || this.readNumber()
const props = this.readProperties(!variable)
if (!props.length) return variable
return new PropertyAccessToken(variable, props, this.input, begin, this.p)
Expand Down Expand Up @@ -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
Comment thread
skynetigor marked this conversation as resolved.
Outdated
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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing is no longer near O(N) complexity with findMatchingParen and findMatchingParen introduced.

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<TopLevelToken> {
const { outputDelimiterLeft } = options
const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft]
Expand Down
10 changes: 8 additions & 2 deletions src/render/expression.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<unknown> {
assert(token.resolvedValue, 'grouped expression not resolved')
return yield token.resolvedValue!.value(ctx, lenient)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to create something like evalGroupedExpressionToken(), instead of pre populate resolvedValue, which can be scattered in multiple places.

}

function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
Expand Down
6 changes: 4 additions & 2 deletions src/tags/case.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(',')
Expand Down
3 changes: 2 additions & 1 deletion src/tags/for.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand All @@ -26,6 +26,7 @@ export default class extends Tag {

this.variable = variable.content
this.collection = collection
resolveGroupedExpressions(this.collection, liquid)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be removed now.

this.hash = new Hash(this.tokenizer, liquid.options.keyValueSeparator)
this.templates = []
this.elseTemplates = []
Expand Down
11 changes: 11 additions & 0 deletions src/template/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -371,6 +372,16 @@ function * extractValueTokenVariables (token: ValueToken): Generator<Variable> {
if (isRangeToken(token)) {
yield * extractValueTokenVariables(token.lhs)
yield * extractValueTokenVariables(token.rhs)
} else if (isGroupedExpressionToken(token)) {
for (const t of token.initial.postfix) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create a extractGroupedExpressionTokenVariables

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)
}
Expand Down
2 changes: 1 addition & 1 deletion src/template/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class Output extends TemplateImpl<OutputToken> 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
Expand Down
27 changes: 24 additions & 3 deletions src/template/value.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this should be moved to evalGroupedExpressionToken(), and if some structures can be created in parse time, need to be done in GroupedExpressionToken#constructor and consumed during evalGroupedExpressionToken()

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
Expand All @@ -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<unknown, unknown, unknown> {
Expand Down
18 changes: 18 additions & 0 deletions src/tokens/grouped-expression-token.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown, unknown> }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

templates (include Value) depends on tokens, tokens don't depends on templates

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)
}
}
1 change: 1 addition & 0 deletions src/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion src/tokens/liquid-tag-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading