mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-15 04:10:40 -07:00
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]>
This commit is contained in:
co-authored by
Omri Rosner
parent
39c87437c5
commit
afa5f54004
@@ -1085,4 +1085,24 @@ describe('Variable analysis', () => {
|
||||
locals: { y: [new Variable(['y'], { row: 1, col: 11, file: 'a' })] }
|
||||
})
|
||||
})
|
||||
|
||||
describe('grouped expressions', () => {
|
||||
const ge = new Liquid({ groupedExpressions: true })
|
||||
|
||||
it('should report variables inside a grouped output expression', () => {
|
||||
const analysis = analyzeSync(ge.parse('{{ (a | append: b) }}'))
|
||||
expect(Object.keys(analysis.variables).sort()).toStrictEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('should report variables inside a grouped condition', () => {
|
||||
const analysis = analyzeSync(ge.parse('{% if (a | append: b) == c %}{% endif %}'))
|
||||
expect(Object.keys(analysis.globals).sort()).toStrictEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('should separate locals from globals for grouped assign', () => {
|
||||
const analysis = analyzeSync(ge.parse('{% assign x = (a | upcase) %}{{ x }}'))
|
||||
expect(Object.keys(analysis.globals)).toStrictEqual(['a'])
|
||||
expect(Object.keys(analysis.locals)).toStrictEqual(['x'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,4 +99,13 @@ describe('tags/assign', function () {
|
||||
const html = liquid.parseAndRenderSync(src)
|
||||
return expect(html).toBe('bar')
|
||||
})
|
||||
describe('grouped expressions', function () {
|
||||
const ge = new Liquid({ groupedExpressions: true })
|
||||
it('should assign a grouped expression', async () => {
|
||||
expect(await ge.parseAndRender('{% assign x = (name | upcase) %}{{ x }}', { name: 'bar' })).toBe('BAR')
|
||||
})
|
||||
it('should assign a grouped range', async () => {
|
||||
expect(await ge.parseAndRender('{% assign x = (1..(items | size)) %}{{ x }}', { items: ['a', 'b', 'c'] })).toBe('123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,4 +36,14 @@ describe('tags/echo', function () {
|
||||
const html = await liquid.parseAndRender(src, { user: { name: 'Sally' } })
|
||||
return expect(html).toBe('Hello, SALLY!')
|
||||
})
|
||||
|
||||
describe('grouped expressions', function () {
|
||||
const ge = new Liquid({ groupedExpressions: true })
|
||||
it('should echo a grouped expression', async () => {
|
||||
expect(await ge.parseAndRender('{% echo (name | upcase) %}', { name: 'bar' })).toBe('BAR')
|
||||
})
|
||||
it('should render a grouped expression in an output statement', async () => {
|
||||
expect(await ge.parseAndRender('{{ (name | upcase | append: "!") }}', { name: 'bar' })).toBe('BAR!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,4 +169,84 @@ 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 real parenthesis grouping with comparisons and and', async function () {
|
||||
const src = '{% if (((name | downcase | size) > 3) and (one < three)) %}long{% else %}short{% endif %}'
|
||||
const html = await ge.parseAndRender(src, { name: 'Alice', one: 1, three: 3 })
|
||||
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')
|
||||
})
|
||||
it('should support or with grouped operands', async function () {
|
||||
const src = '{% if (a | upcase) == "X" or (b | upcase) == "B" %}yes{% else %}no{% endif %}'
|
||||
expect(await ge.parseAndRender(src, { a: 'z', b: 'b' })).toBe('yes')
|
||||
})
|
||||
it('should support not with a grouped operand', async function () {
|
||||
const src = '{% if not (a | upcase) == "B" %}no{% else %}yes{% endif %}'
|
||||
expect(await ge.parseAndRender(src, { a: 'b' })).toBe('yes')
|
||||
})
|
||||
it('should support contains with a grouped operand', async function () {
|
||||
const src = '{% if (csv | split: ",") contains "b" %}yes{% else %}no{% endif %}'
|
||||
expect(await ge.parseAndRender(src, { csv: 'a,b,c' })).toBe('yes')
|
||||
})
|
||||
it('should support property access on a grouped result', function () {
|
||||
expect(ge.evalValueSync('(items | first).name', { items: [{ name: 'Sally' }] })).toBe('Sally')
|
||||
})
|
||||
it('should support a grouped expression inside a bracket index', function () {
|
||||
expect(ge.evalValueSync('arr[(i | plus: 1)]', { arr: [10, 20, 30], i: 1 })).toBe(30)
|
||||
})
|
||||
it('should support an async filter inside a grouped expression', async function () {
|
||||
ge.registerFilter('asyncUpcase', (v: string) => Promise.resolve(String(v).toUpperCase()))
|
||||
const src = '{% if (name | asyncUpcase) == "BAR" %}yes{% else %}no{% endif %}'
|
||||
expect(await ge.parseAndRender(src, { name: 'bar' })).toBe('yes')
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user