Files
liquidjs/src/parser/tokenizer.spec.ts
T
afa5f54004 feat: Add support of inner expressions enclosed by parentheses (#863)
* Add support of inner expressions enclosed by parentheses

* Add support of inner expressions enclosed by parentheses

Made-with: Cursor

* simplify implementation

* fix lint

* fix test

* 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: 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.

* 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.

* refactor(types): explicit type for collection in for tag

collection: ValueToken | GroupedExpressionToken

Addresses PR #863 comment 5.

* refactor: evaluate grouped expressions at render time with resolvedFilters

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.

* refactor: reuse FilteredValueToken and fix architectural layering

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.

* revert redundant'

* refactor: make getFilter private and improve code organization

* test: fix test name in case.spec.ts for when disabled block

* refactor: no need for Deprecated flag

* test: fix test name and logic to properly test if tag with nested expressions

* feat: support real parenthesis grouping in grouped expressions

Allow arbitrary expressions inside parentheses (e.g. ((a | upcase) > 3)
and (1 < 3)) when groupedExpressions is enabled, reusing readFilteredValue
for the general case while keeping range and filter-chain fast paths.

* feat: enhance expression tokenization with new generator methods

Added `readExpressionTokensFromHere` and `readGroupedExpressionTokens` methods to improve the handling of expression tokens. This refactor simplifies the token reading process and maintains compatibility with existing grouped expressions, ensuring proper evaluation and filtering.

* add tests

* address comments

---------

Co-authored-by: Omri Rosner <[email protected]>
2026-08-01 18:15:54 +08:00

678 lines
28 KiB
TypeScript

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'
describe('Tokenizer', function () {
it('should read quoted', () => {
expect(new Tokenizer('"foo" ff').readQuoted()!.getText()).toBe('"foo"')
expect(new Tokenizer(' "foo"ff').readQuoted()!.getText()).toBe('"foo"')
})
it('should read value', () => {
expect(new Tokenizer('a[ b][ "c d" ]').readValueOrThrow().getText()).toBe('a[ b][ "c d" ]')
expect(new Tokenizer('a.b[c[d.e]]').readValueOrThrow().getText()).toBe('a.b[c[d.e]]')
})
it('should read identifier', () => {
expect(new Tokenizer('foo bar').readIdentifier()).toHaveProperty('content', 'foo')
// eslint-disable-next-line deprecation/deprecation
expect(new Tokenizer('foo bar').readWord()).toHaveProperty('content', 'foo')
})
it('should read integer number', () => {
const token: NumberToken = new Tokenizer('123').readValueOrThrow() as any
expect(token).toBeInstanceOf(NumberToken)
expect(token.getText()).toBe('123')
expect(token.content).toBe(123)
})
it('should read negative number', () => {
const token: NumberToken = new Tokenizer('-123').readValueOrThrow() as any
expect(token).toBeInstanceOf(NumberToken)
expect(token.getText()).toBe('-123')
expect(token.content).toBe(-123)
})
it('should read float number', () => {
const token: NumberToken = new Tokenizer('1.23').readValueOrThrow() as any
expect(token).toBeInstanceOf(NumberToken)
expect(token.getText()).toBe('1.23')
expect(token.content).toBe(1.23)
})
it('should treat 1.2.3 as property read', () => {
const token: PropertyAccessToken = new Tokenizer('1.2.3').readValueOrThrow() as any
expect(token).toBeInstanceOf(PropertyAccessToken)
expect(token.props).toHaveLength(3)
expect(token.props[0].getText()).toBe('1')
expect(token.props[1].getText()).toBe('2')
expect(token.props[2].getText()).toBe('3')
})
it('should read quoted value', () => {
const value = new Tokenizer('"foo"a').readValue()
expect(value).toBeInstanceOf(QuotedToken)
expect(value!.getText()).toBe('"foo"')
})
it('should read property access value', () => {
expect(new Tokenizer('a[b]["c d"]').readValueOrThrow().getText()).toBe('a[b]["c d"]')
})
it('should read quoted property access value', () => {
const value = new Tokenizer('["a prop"]').readValue()
expect(value).toBeInstanceOf(PropertyAccessToken)
expect((value as QuotedToken).getText()).toBe('["a prop"]')
})
it('should throw for incomplete quoted property access', () => {
const tokenizer = new Tokenizer('["a prop"')
expect(() => tokenizer.readValueOrThrow()).toThrow()
})
it('should read hash', () => {
const hash1 = new Tokenizer('foo: 3').readHash()
expect(hash1!.name.content).toBe('foo')
expect(hash1!.value!.getText()).toBe('3')
const hash2 = new Tokenizer(', foo: a[ "bar"]').readHash()
expect(hash2!.name.content).toBe('foo')
expect(hash2!.value!.getText()).toBe('a[ "bar"]')
})
it('should read multiple hashes', () => {
const hashes = new Tokenizer(', limit: 3 reverse offset:off').readHashes()
expect(hashes).toHaveLength(3)
const [limit, reverse, offset] = hashes
expect(limit.name.content).toBe('limit')
expect(limit.value!.getText()).toBe('3')
expect(reverse.name.content).toBe('reverse')
expect(reverse.value).toBeUndefined()
expect(offset.name.content).toBe('offset')
expect(offset.value!.getText()).toBe('off')
})
it('should read hash value with property access', () => {
const hashes = new Tokenizer('cols: 2, rows: data["rows"]').readHashes()
expect(hashes).toHaveLength(2)
const [cols, rols] = hashes
expect(cols.name.content).toBe('cols')
expect(cols.value!.getText()).toBe('2')
expect(rols.name.content).toBe('rows')
expect(rols.value!.getText()).toBe('data["rows"]')
})
describe('#readTopLevelTokens()', () => {
it('should read HTML token', function () {
const html = '<html><body><p>Lorem Ipsum</p></body></html>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
expect(tokens[0]).toBeInstanceOf(HTMLToken)
expect((tokens[0] as HTMLToken).getContent()).toBe(html)
})
it('should read tag token', function () {
const html = '<p>{% for p in a[1]%}</p>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(3)
const tag = tokens[1] as TagToken
expect(tag).toBeInstanceOf(TagToken)
expect(tag.name).toBe('for')
expect(tag.args).toBe('p in a[1]')
})
it('should allow unclosed tag inside {% raw %}', function () {
const html = '{%raw%} {%if%} {%else {%endraw%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(3)
expect(tokens[0]).toHaveProperty('name', 'raw')
expect((tokens[1] as any).getContent()).toBe(' {%if%} {%else ')
})
it('should allow unclosed endraw tag inside {% raw %}', function () {
const html = '{%raw%} {%endraw {%raw%} {%endraw%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(3)
expect(tokens[0]).toHaveProperty('name', 'raw')
expect((tokens[1] as any).getContent()).toBe(' {%endraw {%raw%} ')
})
it('should throw when {% raw %} not closed', function () {
const html = '{%raw%} {%endraw {%raw%}'
const tokenizer = new Tokenizer(html)
expect(() => tokenizer.readTopLevelTokens()).toThrow('raw "{%raw%} {%endraw {%raw%}" not closed, line:1, col:8')
})
it('should read output token', function () {
const html = '<p>{{foo | date: "%Y-%m-%d"}}</p>'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(3)
const output = tokens[1] as OutputToken
expect(output).toBeInstanceOf(OutputToken)
expect(output.content).toBe('foo | date: "%Y-%m-%d"')
})
it('should handle consecutive value and tags', function () {
const html = '{{foo}}{{bar}}{%foo%}{%bar%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(4)
const o1 = tokens[0] as OutputToken
const o2 = tokens[1] as OutputToken
const t1 = tokens[2] as TagToken
const t2 = tokens[3] as TagToken
expect(o1).toBeInstanceOf(OutputToken)
expect(o2).toBeInstanceOf(OutputToken)
expect(t1).toBeInstanceOf(TagToken)
expect(t2).toBeInstanceOf(TagToken)
expect(o1.content).toBe('foo')
expect(o2.content).toBe('bar')
expect(t1.name).toBe('foo')
expect(t1.args).toBe('')
expect(t2.name).toBe('bar')
expect(t2.args).toBe('')
})
it('should keep white spaces and newlines', function () {
const html = '{%foo%}\n{%bar %} \n {%alice%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(5)
expect(tokens[1]).toBeInstanceOf(HTMLToken)
expect(tokens[1].getText()).toBe('\n')
expect(tokens[3]).toBeInstanceOf(HTMLToken)
expect(tokens[3].getText()).toBe(' \n ')
})
it('should handle multiple lines tag', function () {
const html = '{%foo\na:a\nb:1.23\n%}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
expect(tokens[0]).toBeInstanceOf(TagToken)
expect((tokens[0] as TagToken).args).toBe('a:a\nb:1.23')
expect(tokens[0].getText()).toBe('{%foo\na:a\nb:1.23\n%}')
})
it('should handle multiple lines value', function () {
const html = '{{foo\n|date:\n"%Y-%m-%d"\n}}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
expect(tokens[0]).toBeInstanceOf(OutputToken)
expect(tokens[0].getText()).toBe('{{foo\n|date:\n"%Y-%m-%d"\n}}')
})
it('should handle complex object property access', function () {
const html = '{{ obj["my:property with anything"] }}'
const tokenizer = new Tokenizer(html)
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
const output = tokens[0] as OutputToken
expect(output).toBeInstanceOf(OutputToken)
expect(output.content).toBe('obj["my:property with anything"]')
})
it('should throw if tag not closed', function () {
const html = '{% assign foo = bar {{foo}}'
const tokenizer = new Tokenizer(html)
expect(() => tokenizer.readTopLevelTokens()).toThrow('tag "{% assign foo = bar {{foo}}" not closed, line:1, col:1')
})
it('should throw if output not closed', function () {
const tokenizer = new Tokenizer('{{name}')
expect(() => tokenizer.readTopLevelTokens()).toThrow(/output "{{name}" not closed/)
})
})
describe('#readTagToken()', () => {
})
describe('#readOutputToken()', () => {
it('should skip quoted delimiters', function () {
const html = '{{ "%} {%" | append: "}} {{" }}'
const tokenizer = new Tokenizer(html)
const token = tokenizer.readOutputToken()
expect(token).toBeInstanceOf(OutputToken)
expect(token.content).toBe('"%} {%" | append: "}} {{"')
})
})
describe('#readRange()', () => {
it('should read `(1..3)`', () => {
const range = new Tokenizer('(1..3)').readGroupOrRange()
expect(range).toBeDefined()
expect(range).toBeInstanceOf(RangeToken)
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 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 read `((a | upcase) > 3)` as outer FilteredValueToken with comparison inside parens', () => {
const token = createGrouped('((a | upcase) > 3)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const outer = token as FilteredValueToken
expect(outer.filters).toHaveLength(0)
expect(outer.getText()).toBe('((a | upcase) > 3)')
const [first, second, third] = outer.initial.postfix
expect(first).toBeInstanceOf(FilteredValueToken)
expect(second).toBeInstanceOf(NumberToken)
expect(third).toBeInstanceOf(OperatorToken)
expect((first as FilteredValueToken).filters[0].name).toBe('upcase')
})
it('should read `(1 < 3)` as grouped comparison with no filters', () => {
const token = createGrouped('(1 < 3)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(3)
expect(token.initial.postfix[0]).toBeInstanceOf(NumberToken)
expect(token.initial.postfix[1]).toBeInstanceOf(NumberToken)
expect((token.initial.postfix[2] as OperatorToken).operator).toBe('<')
})
it('should read redundant parens `(x)` as FilteredValueToken', () => {
const token = createGrouped('(x)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(1)
})
it('should read expression plus filters inside parens `(a == b | default: "x")`', () => {
const token = createGrouped('(a == b | default: "x")').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(1)
expect(token.filters[0].name).toBe('default')
expect(token.initial.postfix.map((t) => t.getText()).join(' ')).toMatch(/a.*b.*==/)
})
it('should parse `((a | upcase) > 3) and (1 < 3)` as three expression tokens', () => {
const exp = [...createGrouped('((a | upcase) > 3) and (1 < 3)').readExpressionTokens()]
expect(exp).toHaveLength(3)
expect(exp[0]).toBeInstanceOf(FilteredValueToken)
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toBe('and')
expect(exp[2]).toBeInstanceOf(FilteredValueToken)
})
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')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token).toHaveProperty('args', [])
})
it('should read a filter with argument', function () {
const tokenizer = new Tokenizer(' | plus: 1')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token!.args).toHaveLength(1)
const one: NumberToken = token!.args[0] as any
expect(one).toBeInstanceOf(NumberToken)
expect(one.getText()).toBe('1')
})
it('should read a filter with colon but no argument', function () {
const tokenizer = new Tokenizer('| plus:')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token).toHaveProperty('args', [])
})
it('should read null if name not found', function () {
const tokenizer = new Tokenizer('|')
const token = tokenizer.readFilter()
expect(token).toBeNull()
})
it('should read a filter with k/v argument', function () {
const tokenizer = new Tokenizer(' | plus: a:1')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token!.args).toHaveLength(1)
const [k, v]: [string, NumberToken] = token!.args[0] as any
expect(k).toBe('a')
expect(v).toBeInstanceOf(NumberToken)
expect(v.getText()).toBe('1')
})
it('should read a filter with "arr[0]" argument', function () {
const tokenizer = new Tokenizer('| plus: arr[0]')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token!.args).toHaveLength(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect(pa.props).toHaveLength(2)
expect((pa.props[0] as any).content).toBe('arr')
expect(pa.props[1]).toBeInstanceOf(NumberToken)
expect(pa.props[1].getText()).toBe('0')
})
it('should read a filter with obj.foo argument', function () {
const tokenizer = new Tokenizer('| plus: obj.foo')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token!.args).toHaveLength(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect(pa.props).toHaveLength(2)
expect((pa.props[0] as any).content).toBe('obj')
expect(pa.props[1]).toBeInstanceOf(IdentifierToken)
expect(pa.props[1].getText()).toBe('foo')
})
it('should read a filter with obj["foo"] argument', function () {
const tokenizer = new Tokenizer('| plus: obj["good luck"]')
const token = tokenizer.readFilter()
expect(token).toHaveProperty('name', 'plus')
expect(token!.args).toHaveLength(1)
const pa: PropertyAccessToken = token!.args[0] as any
expect(token!.args[0]).toBeInstanceOf(PropertyAccessToken)
expect(pa.getText()).toBe('obj["good luck"]')
expect((pa.props[0] as any).content).toBe('obj')
expect(pa.props[1].getText()).toBe('"good luck"')
})
})
describe('#readFilters()', () => {
it('should read simple filters', function () {
const tokenizer = new Tokenizer('| plus: 3 | capitalize')
const tokens = tokenizer.readFilters()
expect(tokens).toHaveLength(2)
expect(tokens[0]).toHaveProperty('name', 'plus')
expect(tokens[0].args).toHaveLength(1)
expect(tokens[0].args[0]).toBeInstanceOf(NumberToken)
expect((tokens[0].args[0] as any).getText()).toBe('3')
expect(tokens[1]).toHaveProperty('name', 'capitalize')
expect(tokens[1].args).toHaveLength(0)
})
it('should read filters', function () {
const tokenizer = new Tokenizer('| plus: a:3 | capitalize | append: foo[a.b["c d"]]')
const tokens = tokenizer.readFilters()
expect(tokens).toHaveLength(3)
expect(tokens[0]).toHaveProperty('name', 'plus')
expect(tokens[0].args).toHaveLength(1)
const [k, v]: [string, NumberToken] = tokens[0].args[0] as any
expect(k).toBe('a')
expect(v).toBeInstanceOf(NumberToken)
expect(v.getText()).toBe('3')
expect(tokens[1]).toHaveProperty('name', 'capitalize')
expect(tokens[1].args).toHaveLength(0)
expect(tokens[2]).toHaveProperty('name', 'append')
expect(tokens[2].args).toHaveLength(1)
expect(tokens[2].args[0]).toBeInstanceOf(PropertyAccessToken)
expect((tokens[2].args[0] as any).getText()).toBe('foo[a.b["c d"]]')
expect((tokens[2].args[0] as any).props[1].getText()).toBe('a.b["c d"]')
})
})
describe('#readExpression()', () => {
it('should read expression `a `', () => {
const exp = [...new Tokenizer('a ').readExpressionTokens()]
expect(exp).toHaveLength(1)
expect(exp[0]).toBeInstanceOf(PropertyAccessToken)
expect(exp[0].getText()).toEqual('a')
})
it('should read expression `a[][b]`', () => {
const exp = [...new Tokenizer('a[][b]').readExpressionTokens()]
expect(exp).toHaveLength(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).toBeInstanceOf(PropertyAccessToken)
expect(pa.props).toHaveLength(3)
expect((pa.props[0] as any).content).toEqual('a')
const [, p1, p2] = pa.props
expect(p1).toBeInstanceOf(IdentifierToken)
expect(p1.getText()).toBe('')
expect(p2).toBeInstanceOf(PropertyAccessToken)
expect(p2.getText()).toBe('b')
})
it('should read expression `a.`', () => {
const exp = [...new Tokenizer('a.').readExpressionTokens()]
expect(exp).toHaveLength(1)
const pa = exp[0] as PropertyAccessToken
expect(pa).toBeInstanceOf(PropertyAccessToken)
expect(pa.props).toHaveLength(1)
expect((pa.props[0] as any).content).toEqual('a')
})
it('should read expression `a ==`', () => {
const exp = [...new Tokenizer('a ==').readExpressionTokens()]
expect(exp).toHaveLength(2)
expect(exp[0]).toBeInstanceOf(PropertyAccessToken)
expect(exp[0].getText()).toEqual('a')
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toEqual('==')
})
it('should read expression `a==b`', () => {
const exp = new Tokenizer('a==b').readExpressionTokens()
const [a, equals, b] = exp
expect(a).toBeInstanceOf(PropertyAccessToken)
expect(a.getText()).toEqual('a')
expect(equals).toBeInstanceOf(OperatorToken)
expect(equals.getText()).toBe('==')
expect(b).toBeInstanceOf(PropertyAccessToken)
expect(b.getText()).toEqual('b')
})
it('should read expression `^`', () => {
const exp = new Tokenizer('^').readExpressionTokens()
expect([...exp]).toEqual([])
})
it('should read expression `a == b`', () => {
const exp = new Tokenizer('a == b').readExpressionTokens()
const [a, equals, b] = exp
expect(a).toBeInstanceOf(PropertyAccessToken)
expect(a.getText()).toEqual('a')
expect(equals).toBeInstanceOf(OperatorToken)
expect(equals.getText()).toBe('==')
expect(b).toBeInstanceOf(PropertyAccessToken)
expect(b.getText()).toEqual('b')
})
it('should read expression `(1..3) contains 3`', () => {
const exp = new Tokenizer('(1..3) contains 3').readExpressionTokens()
const [range, contains, rhs] = exp
expect(range).toBeInstanceOf(RangeToken)
expect(range.getText()).toEqual('(1..3)')
expect(contains).toBeInstanceOf(OperatorToken)
expect(contains.getText()).toBe('contains')
expect(rhs).toBeInstanceOf(NumberToken)
expect(rhs.getText()).toEqual('3')
})
it('should read expression `a[b] == c`', () => {
const exp = new Tokenizer('a[b] == c').readExpressionTokens()
const [lhs, contains, rhs] = exp
expect(lhs).toBeInstanceOf(PropertyAccessToken)
expect(lhs.getText()).toEqual('a[b]')
expect(contains).toBeInstanceOf(OperatorToken)
expect(contains.getText()).toBe('==')
expect(rhs).toBeInstanceOf(PropertyAccessToken)
expect(rhs.getText()).toEqual('c')
})
it('should read expression `c[a["b"]] >= c`', () => {
const exp = new Tokenizer('c[a["b"]] >= c').readExpressionTokens()
const [lhs, op, rhs] = exp
expect(lhs).toBeInstanceOf(PropertyAccessToken)
expect(lhs.getText()).toEqual('c[a["b"]]')
expect(op).toBeInstanceOf(OperatorToken)
expect(op.getText()).toBe('>=')
expect(rhs).toBeInstanceOf(PropertyAccessToken)
expect(rhs.getText()).toEqual('c')
})
it('should read expression `"][" == var`', () => {
const exp = new Tokenizer('"][" == var').readExpressionTokens()
const [lhs, equals, rhs] = exp
expect(lhs).toBeInstanceOf(QuotedToken)
expect(lhs.getText()).toEqual('"]["')
expect(equals).toBeInstanceOf(OperatorToken)
expect(equals.getText()).toBe('==')
expect(rhs).toBeInstanceOf(PropertyAccessToken)
expect(rhs.getText()).toEqual('var')
})
it('should read expression `"\\\'" == "\\""`', () => {
const exp = new Tokenizer('"\\\'" == "\\""').readExpressionTokens()
const [lhs, equals, rhs] = exp
expect(lhs).toBeInstanceOf(QuotedToken)
expect(lhs.getText()).toEqual('"\\\'"')
expect(equals).toBeInstanceOf(OperatorToken)
expect(equals.getText()).toBe('==')
expect(rhs).toBeInstanceOf(QuotedToken)
expect(rhs.getText()).toEqual('"\\""')
})
})
describe('#matchTrie()', function () {
const opTrie = createTrie(defaultOperators)
it('should match contains', () => {
expect(new Tokenizer('contains').matchTrie(opTrie)).toBe(8)
})
it('should match comparison', () => {
expect(new Tokenizer('>').matchTrie(opTrie)).toBe(1)
expect(new Tokenizer('>=').matchTrie(opTrie)).toBe(2)
expect(new Tokenizer('<').matchTrie(opTrie)).toBe(1)
expect(new Tokenizer('<=').matchTrie(opTrie)).toBe(2)
})
it('should match binary logic', () => {
expect(new Tokenizer('and').matchTrie(opTrie)).toBe(3)
expect(new Tokenizer('or').matchTrie(opTrie)).toBe(2)
})
it('should not match if word not terminate', () => {
expect(new Tokenizer('true1').matchTrie(opTrie)).toBe(-1)
expect(new Tokenizer('containsa').matchTrie(opTrie)).toBe(-1)
})
it('should match if word boundary found', () => {
expect(new Tokenizer('>=1').matchTrie(opTrie)).toBe(2)
expect(new Tokenizer('contains b').matchTrie(opTrie)).toBe(8)
})
})
describe('#createTrie()', function () {
it('should return the same trie for the same input', () => {
expect(createTrie(defaultOperators)).toBe(createTrie(defaultOperators))
})
it('should return distinct tries for distinct inputs', () => {
expect(createTrie({ foo: 1 })).not.toBe(createTrie({ foo: 1 }))
})
})
describe('#readLiquidTagTokens', () => {
it('should read newline terminated tokens', () => {
const tokenizer = new Tokenizer('echo \'hello\'')
const tokens = tokenizer.readLiquidTagTokens()
expect(tokens.length).toBe(1)
const tag = tokens[0]
expect(tag).toBeInstanceOf(LiquidTagToken)
expect(tag.name).toBe('echo')
expect(tag.args).toBe('\'hello\'')
})
it('should gracefully handle empty lines', () => {
const tokenizer = new Tokenizer(`
echo 'hello'
decrement foo
`)
const tokens = tokenizer.readLiquidTagTokens()
expect(tokens.length).toBe(2)
})
it('should throw if line does not start with an identifier', () => {
const tokenizer = new Tokenizer('!')
expect(() => tokenizer.readLiquidTagTokens()).toThrow(/illegal liquid tag syntax/)
})
})
describe('#read inline comment tags', () => {
it('should allow hash characters in tag names', () => {
const tokenizer = new Tokenizer('{% # some comment %}')
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
const tag = tokens[0] as TagToken
expect(tag).toBeInstanceOf(TagToken)
expect(tag.name).toBe('#')
expect(tag.args).toBe('some comment')
})
it('should handle leading whitespace', () => {
const tokenizer = new Tokenizer('{%\n # some comment %}')
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
const tag = tokens[0] as TagToken
expect(tag).toBeInstanceOf(TagToken)
expect(tag.name).toBe('#')
expect(tag.args).toBe('some comment')
})
it('should handle no trailing whitespace', () => {
const tokenizer = new Tokenizer('{%\n #some comment %}')
const tokens = tokenizer.readTopLevelTokens()
expect(tokens.length).toBe(1)
const tag = tokens[0] as TagToken
expect(tag).toBeInstanceOf(TagToken)
expect(tag.name).toBe('#')
expect(tag.args).toBe('some comment')
})
})
})