diff --git a/src/twig.expression.js b/src/twig.expression.js index 50f85d67..8586cb9f 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -64,7 +64,9 @@ module.exports = function (Twig) { number: 'Twig.expression.type.number', _null: 'Twig.expression.type.null', context: 'Twig.expression.type.context', - test: 'Twig.expression.type.test' + test: 'Twig.expression.type.test', + arrowOperator: 'Twig.expression.type.arrowOperator', + arrowFunction: 'Twig.expression.type.arrowFunction' }; Twig.expression.set = { @@ -387,7 +389,7 @@ module.exports = function (Twig) { */ type: Twig.expression.type.subexpression.end, regex: /^\)/, - next: Twig.expression.set.operationsExtended, + next: Twig.expression.set.operationsExtended.concat([Twig.expression.type.arrowOperator]), validate(match, tokens) { // Iterate back through previous tokens to ensure we follow a subexpression start let i = tokens.length - 1; @@ -489,7 +491,10 @@ module.exports = function (Twig) { */ type: Twig.expression.type.parameter.start, regex: /^\(/, - next: Twig.expression.set.expressions.concat([Twig.expression.type.parameter.end]), + next: Twig.expression.set.expressions.concat([ + Twig.expression.type.parameter.end, + Twig.expression.type.arrowFunction + ]), validate(match, tokens) { const lastToken = tokens[tokens.length - 1]; // We can't use the regex to test if we follow a space because expression is trimmed @@ -504,7 +509,7 @@ module.exports = function (Twig) { */ type: Twig.expression.type.parameter.end, regex: /^\)/, - next: Twig.expression.set.operationsExtended, + next: Twig.expression.set.operationsExtended.concat([Twig.expression.type.arrowOperator]), compile(token, stack, output) { let stackToken; const endToken = token; @@ -866,7 +871,8 @@ module.exports = function (Twig) { // Match any letter or _, then any number of letters, numbers, _ or - regex: /^[a-zA-Z_]\w*/, next: Twig.expression.set.operationsExtended.concat([ - Twig.expression.type.parameter.start + Twig.expression.type.parameter.start, + Twig.expression.type.arrowOperator ]), compile: Twig.expression.fn.compile.push, validate(match) { @@ -886,6 +892,46 @@ module.exports = function (Twig) { }); } }, + { + /** + * Match the arrow function operator (=>). + */ + type: Twig.expression.type.arrowOperator, + regex: /^=>/, + next: Twig.expression.set.expressions, + validate(match, tokens) { + const last = tokens[tokens.length - 1]; + if (!last) return false; + // Only valid after an identifier or a closing paren that ends the parameter list. + return ( + last.type === Twig.expression.type.variable || + last.type === Twig.expression.type.parameter.end || + last.type === Twig.expression.type.subexpression.end + ); + }, + compile(token, stack, output) { + throw new Twig.Error('Unexpected arrow operator. Arrow was not pre-processed.'); + }, + parse() { + throw new Twig.Error('Unexpected arrow operator during parse.'); + } + }, + { + /** + * Placeholder for a compiled arrow function (emitted by preprocessArrows, not matched when tokenizing). + */ + type: Twig.expression.type.arrowFunction, + regex: /^$/, + next: Twig.expression.set.operations.concat([ + Twig.expression.type.parameter.end, + Twig.expression.type.comma + ]), + compile: Twig.expression.fn.compile.push, + parse(token, stack, context) { + token.capturedContext = context; + stack.push(token); + } + }, { type: Twig.expression.type.key.period, regex: /^\.(\w+)/, @@ -1271,6 +1317,134 @@ module.exports = function (Twig) { return tokens; }; + /** + * Collapse arrow syntax into atomic arrowFunction tokens (parameter names and body in RPN) before + * shunting-yard compilation. + * + * @param {Array} tokens Flat tokens from tokenize. + * + * @return {Array} Tokens with arrow spans replaced by arrowFunction entries. + */ + Twig.expression.preprocessArrows = function (tokens) { + const result = []; + let i = 0; + + while (i < tokens.length) { + if (tokens[i].type !== Twig.expression.type.arrowOperator) { + result.push(tokens[i]); + i++; + continue; + } + + // Extract parameter names from tokens already emitted to the result queue. + const paramNames = []; + const preceding = result[result.length - 1]; + + if (preceding.type === Twig.expression.type.variable) { + // Single identifier before => (e.g. v => expr). + result.pop(); + paramNames.push(preceding.value); + } else if ( + preceding.type === Twig.expression.type.parameter.end || + preceding.type === Twig.expression.type.subexpression.end + ) { + // Parenthesized parameter list before => (e.g. (v, k) => expr). + result.pop(); // Remove the closing parenthesis. + const innerTokens = []; + while (result.length > 0) { + const t = result.pop(); + if (t.type === Twig.expression.type.parameter.start || + t.type === Twig.expression.type.subexpression.start) { + break; + } + innerTokens.unshift(t); + } + for (const t of innerTokens) { + if (t.type === Twig.expression.type.variable) { + paramNames.push(t.value); + } + } + } else { + // Defensive: normal tokenization enforces the same predecessors as arrowOperator.validate. + throw new Twig.Error('Invalid arrow function: unexpected token before =>'); + } + + // Collect body tokens; depth tracks nesting so commas inside nested structures do not end the body. + i++; // Advance past the arrow operator. + const bodyTokens = []; + let depth = 0; + + while (i < tokens.length) { + const next = tokens[i]; + + if (next.type === Twig.expression.type.parameter.start || + next.type === Twig.expression.type.subexpression.start || + next.type === Twig.expression.type.array.start || + next.type === Twig.expression.type.object.start) { + depth++; + } else if ( + next.type === Twig.expression.type.parameter.end || + next.type === Twig.expression.type.subexpression.end || + next.type === Twig.expression.type.array.end || + next.type === Twig.expression.type.object.end + ) { + depth--; + } + + if (depth < 0) break; + if (depth === 0 && next.type === Twig.expression.type.comma) break; + + bodyTokens.push(tokens[i]); + i++; + } + + if (bodyTokens.length === 0) { + throw new Twig.Error('Arrow function has empty body'); + } + + // Recursively preprocess nested arrow functions in the body. + const processedBody = Twig.expression.preprocessArrows(bodyTokens); + + // Compile the body to RPN for the arrowFunction token. + const bodyOutput = []; + const bodyStack = []; + for (const bodyToken of processedBody) { + const handler = Twig.expression.handler[bodyToken.type]; + handler.compile(bodyToken, bodyStack, bodyOutput); + } + while (bodyStack.length > 0) { + bodyOutput.push(bodyStack.pop()); + } + + result.push({ + type: Twig.expression.type.arrowFunction, + params: paramNames, + body: bodyOutput + }); + } + + return result; + }; + + /** + * Evaluate a compiled arrow token by binding parameters in a scoped context and parsing the body RPN. + * Each call clones body tokens because parse mutates them. + * + * @param {Object} arrowToken Compiled arrow function token from preprocessArrows. + * @param {Array} args Argument values passed into the arrow (from the filter). + * @param {Object} state Parser/render state. + * + * @return {Twig.Promise} Promise for the body expression value. + */ + Twig.expression.evaluateArrow = function (arrowToken, args, state) { + const scopedContext = Object.assign({}, arrowToken.capturedContext); + arrowToken.params.forEach((name, i) => { + scopedContext[name] = args[i]; + }); + const bodyClone = arrowToken.body.map(t => Object.assign({}, t)); + return Twig.expression.parseAsync.call(state, bodyClone, scopedContext); + }; + /** * Compile an expression token. * @@ -1280,7 +1454,8 @@ module.exports = function (Twig) { */ Twig.expression.compile = function (rawToken) { // Tokenize expression - const tokens = Twig.expression.tokenize(rawToken); + let tokens = Twig.expression.tokenize(rawToken); + tokens = Twig.expression.preprocessArrows(tokens); let token = null; const output = []; const stack = []; diff --git a/src/twig.filters.js b/src/twig.filters.js index ab49dfad..4147d30f 100644 --- a/src/twig.filters.js +++ b/src/twig.filters.js @@ -8,6 +8,46 @@ module.exports = function (Twig) { return obj !== undefined && obj !== null && clas === type; } + function asyncMerge(left, right, compareFn) { + const result = []; + let li = 0; + let ri = 0; + + function step() { + if (li >= left.length) { + return Twig.Promise.resolve(result.concat(right.slice(ri))); + } + if (ri >= right.length) { + return Twig.Promise.resolve(result.concat(left.slice(li))); + } + + return compareFn(left[li], right[ri]).then(cmp => { + if (cmp <= 0) { + result.push(left[li++]); + } else { + result.push(right[ri++]); + } + return step(); + }); + } + + return step(); + } + + function asyncMergeSort(arr, compareFn) { + if (arr.length <= 1) { + return Twig.Promise.resolve(arr); + } + + const mid = Math.floor(arr.length / 2); + + return asyncMergeSort(arr.slice(0, mid), compareFn).then(left => { + return asyncMergeSort(arr.slice(mid), compareFn).then(right => { + return asyncMerge(left, right, compareFn); + }); + }); + } + Twig.filters = { // String Filters upper(value) { @@ -72,8 +112,29 @@ module.exports = function (Twig) { return value; } }, - sort(value) { + sort(value, params) { if (is('Array', value)) { + if (params && params[0] && params[0].type === Twig.expression.type.arrowFunction) { + const state = this; + const arrowFn = params[0]; + const copy = [...value]; + + try { + copy.sort((a, b) => { + return Twig.async.potentiallyAsync(state, false, () => { + return Twig.expression.evaluateArrow(arrowFn, [a, b], state); + }); + }); + return copy; + } catch (e) { + if (e.message && e.message.includes('async')) { + return asyncMergeSort([...value], (a, b) => { + return Twig.expression.evaluateArrow(arrowFn, [a, b], state); + }); + } + throw e; + } + } return value.sort(); } @@ -122,6 +183,105 @@ module.exports = function (Twig) { return value; } }, + filter(value, params) { + const state = this; + const arrowFn = params[0]; + + if (is('Array', value)) { + return Twig.Promise.all( + value.map((v, k) => Twig.expression.evaluateArrow(arrowFn, [v, k], state)) + ).then(results => value.filter((_, i) => Twig.lib.boolval(results[i]))); + } + + if (is('Object', value)) { + const keys = (value._keys || Object.keys(value)).filter(k => k !== '_keys'); + return Twig.Promise.all( + keys.map(k => Twig.expression.evaluateArrow(arrowFn, [value[k], k], state)) + ).then(results => { + const filtered = {}; + const filteredKeys = []; + keys.forEach((k, i) => { + if (Twig.lib.boolval(results[i])) { + filtered[k] = value[k]; + filteredKeys.push(k); + } + }); + filtered._keys = filteredKeys; + return filtered; + }); + } + + return value; + }, + map(value, params) { + const state = this; + const arrowFn = params[0]; + + if (is('Array', value)) { + return Twig.Promise.all( + value.map((v, k) => Twig.expression.evaluateArrow(arrowFn, [v, k], state)) + ); + } + + if (is('Object', value)) { + const keys = (value._keys || Object.keys(value)).filter(k => k !== '_keys'); + return Twig.Promise.all( + keys.map(k => Twig.expression.evaluateArrow(arrowFn, [value[k], k], state)) + ).then(results => { + const mapped = {}; + keys.forEach((k, i) => { + mapped[k] = results[i]; + }); + mapped._keys = [...keys]; + return mapped; + }); + } + + return value; + }, + reduce(value, params) { + const state = this; + const arrowFn = params[0]; + let carry = params.length > 1 ? params[1] : null; + + const entries = is('Array', value) + ? value.map((v, k) => [k, v]) + : (value._keys || Object.keys(value)).filter(k => k !== '_keys').map(k => [k, value[k]]); + + return Twig.async.forEach(entries, ([k, v]) => { + return Twig.expression.evaluateArrow(arrowFn, [carry, v, k], state) + .then(result => { + carry = result; + }); + }).then(() => carry); + }, + find(value, params) { + const state = this; + const arrowFn = params[0]; + let found = null; + let didFind = false; + + if (!is('Array', value) && !is('Object', value)) { + return value; + } + + const entries = is('Array', value) + ? value.map((v, k) => [k, v]) + : (value._keys || Object.keys(value)).filter(k => k !== '_keys').map(k => [k, value[k]]); + + return Twig.async.forEach(entries, ([k, v]) => { + if (didFind) { + return; + } + return Twig.expression.evaluateArrow(arrowFn, [v, k], state) + .then(result => { + if (Twig.lib.boolval(result)) { + found = v; + didFind = true; + } + }); + }).then(() => found); + }, keys(value) { if (value === undefined || value === null) { return; diff --git a/test/test.expressions.js b/test/test.expressions.js index a7f5a7cc..f90165e4 100644 --- a/test/test.expressions.js +++ b/test/test.expressions.js @@ -543,4 +543,47 @@ describe('Twig.js Expressions ->', function () { output.should.equal('ok!'); }); }); + + describe('Arrow / grouping edge cases ->', function () { + it('should evaluate parenthesized arithmetic (not confused with arrow =>)', function () { + const testTemplate = twig({data: '{{ (a + b) * c }}'}); + testTemplate.render({a: 1, b: 2, c: 3}).should.equal('9'); + }); + + it('should throw when arrow preprocess finds an empty body', function () { + (function () { + twig({ + data: '{{ v => }}', + rethrow: true + }).render(); + }).should.throw('Arrow function has empty body'); + }); + + it('should throw when => is not valid after the preceding token (number)', function () { + (function () { + twig({ + data: '{{ 1 => 2 }}', + rethrow: true + }).render(); + }).should.throw(/Twig\.expression\.type\.arrowOperator cannot follow a Twig\.expression\.type\.number/); + }); + + it('should throw when filter arguments use => without a parameter list', function () { + (function () { + twig({ + data: '{{ [1]|filter(=> true) }}', + rethrow: true + }).render(); + }).should.throw(/Twig\.expression\.type\.arrowOperator cannot follow a Twig\.expression\.type\.parameter\.start/); + }); + + it('should throw when an arrow filter argument has no body after =>', function () { + (function () { + twig({ + data: '{{ [1]|filter(v => ) }}', + rethrow: true + }).render(); + }).should.throw(/Twig\.expression\.type\.(subexpression\.end|parameter\.end) cannot follow a Twig\.expression\.type\.arrowOperator/); + }); + }); }); diff --git a/test/test.filters.js b/test/test.filters.js index c832d2c8..368f662b 100644 --- a/test/test.filters.js +++ b/test/test.filters.js @@ -235,6 +235,499 @@ describe('Twig.js Filters ->', function () { }); }); + describe('filter (arrow) ->', function () { + it('should filter an array by predicate', function () { + return twig({ + data: '{{ [1, 2, 3, 4, 5]|filter(v => v > 3)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('4,5'); + }); + }); + it('should pass key as second argument for arrays', function () { + return twig({ + data: '{{ ["a", "b", "c"]|filter((v, k) => k > 0)|join }}' + }).renderAsync() + .then(output => { + output.should.equal('bc'); + }); + }); + it('should filter an object by value', function () { + return twig({ + data: '{{ {"a": 1, "b": 2}|filter((v, k) => v > 1)|join }}' + }).renderAsync() + .then(output => { + output.should.equal('2'); + }); + }); + it('should use outer context inside the arrow body', function () { + return twig({ + data: '{{ sizes|filter(v => v > min)|join(",") }}' + }).renderAsync({sizes: [38, 40, 42], min: 39}) + .then(output => { + output.should.equal('40,42'); + }); + }); + it('should support nested parentheses in arrow bodies', function () { + return twig({ + data: '{{ [1, 5, 2, 7, 8]|filter(v => (v > 3) and (v < 10))|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('5,7,8'); + }); + }); + it('should support slice/first-char checks in arrow bodies', function () { + return twig({ + data: '{{ ["abc", "bcd", "acd"]|filter(v => v|slice(0, 1) == "a")|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('abc,acd'); + }); + }); + it('should yield empty output when no elements pass the predicate', function () { + return twig({ + data: '{{ [1, 2, 3]|filter(v => v > 10)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should handle empty input array', function () { + return twig({ + data: '{{ []|filter(v => v)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should support single-param parentheses form', function () { + return twig({ + data: '{{ [1, 2, 3]|filter((v) => v > 1)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,3'); + }); + }); + it('should support the is test inside arrows', function () { + return twig({ + data: '{% for v in [1, 2, 3, 4, 5]|filter(v => v is odd) %}{{ v }}{% endfor %}' + }).renderAsync() + .then(output => { + output.should.equal('135'); + }); + }); + it('should support iterating filtered objects in for loops', function () { + return twig({ + data: '{% for k, v in {"a": 1, "b": 40}|filter(v => v > 38) %}{{ k }}={{ v }} {% endfor %}' + }).renderAsync() + .then(output => { + output.should.equal('b=40 '); + }); + }); + it('should run async filter inside arrow bodies (renderAsync)', function () { + Twig.extendFilter('asyncDouble', v => Twig.Promise.resolve(v * 2)); + return twig({ + data: '{{ [1, 2, 3]|filter(v => v|asyncDouble > 3)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,3'); + }); + }); + it('should pass through a scalar string when the value is not an array or plain object', function () { + return twig({ + data: '{{ "ab"|filter(v => true) }}' + }).renderAsync() + .then(output => { + output.should.equal('ab'); + }); + }); + it('should reject when an arrow body references a missing variable and strict_variables is true', function () { + return twig({ + data: '{{ [1]|filter(v => v > missingVar) }}', + rethrow: true, + strict_variables: true + }).renderAsync() + .then(() => { + throw new Error('should have rejected'); + }, err => { + err.message.should.equal('Variable "missingVar" does not exist.'); + }); + }); + }); + + describe('map (arrow) ->', function () { + it('should map array values', function () { + return twig({ + data: '{{ [1, 2, 3]|map(v => v * 2)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,4,6'); + }); + }); + it('should pass key as second argument for arrays', function () { + return twig({ + data: '{{ ["a", "b"]|map((v, k) => v ~ k)|join }}' + }).renderAsync() + .then(output => { + output.should.equal('a0b1'); + }); + }); + it('should map object values preserving keys', function () { + return twig({ + data: '{{ {"x": 1, "y": 2}|map((v, k) => v ~ k)|join }}' + }).renderAsync() + .then(output => { + output.should.equal('1x2y'); + }); + }); + it('should use outer context inside the arrow body', function () { + return twig({ + data: '{{ nums|map(v => v + offset)|join(",") }}' + }).renderAsync({nums: [10, 20], offset: 5}) + .then(output => { + output.should.equal('15,25'); + }); + }); + it('should support ternary in arrow bodies', function () { + return twig({ + data: '{{ [-1, 2, 0]|map(v => v > 0 ? "yes" : "no")|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('no,yes,no'); + }); + }); + it('should support nested filters in arrow bodies', function () { + return twig({ + data: '{{ ["a", "b"]|map(v => v|upper)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('A,B'); + }); + }); + it('should support nested arrows (inner filter uses outer param)', function () { + return twig({ + data: '{{ [2, 3]|map(v => [1, 2, 3]|filter(x => x > v)|join(","))|join("|") }}' + }).renderAsync() + .then(output => { + output.should.equal('3|'); + }); + }); + it('should let arrow parameters shadow outer context names', function () { + return twig({ + data: '{% set v = "outer" %}{{ [1]|map(v => v * 2) }}' + }).renderAsync() + .then(output => { + output.should.equal('2'); + }); + }); + it('should compile arrow map in {% set %} expressions and join the bound variable', function () { + return twig({ + data: '{% set mapped = [1, 2]|map(v => v * 2) %}{{ mapped|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,4'); + }); + }); + it('should format keys in join output (0:a style)', function () { + return twig({ + data: '{{ ["a", "b"]|map((v, k) => k ~ ":" ~ v)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('0:a,1:b'); + }); + }); + it('should map with async filter in arrow body', function () { + Twig.extendFilter('asyncDouble', v => Twig.Promise.resolve(v * 2)); + return twig({ + data: '{{ [1, 2, 3]|map(v => v|asyncDouble)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,4,6'); + }); + }); + it('should pass through a number when the value is not an array or plain object', function () { + return twig({ + data: '{{ 5|map(v => v) }}' + }).renderAsync() + .then(output => { + output.should.equal('5'); + }); + }); + }); + + describe('reduce (arrow) ->', function () { + it('should reduce an array with an initial value', function () { + return twig({ + data: '{{ [1, 2, 3]|reduce((c, v) => c + v, 0) }}' + }).renderAsync() + .then(output => { + output.should.equal('6'); + }); + }); + it('should pass key as third argument for arrays', function () { + return twig({ + data: '{{ [10, 100]|reduce((c, v, k) => c + v * k, 0) }}' + }).renderAsync() + .then(output => { + output.should.equal('100'); + }); + }); + it('should reduce an object', function () { + return twig({ + data: '{{ {"a": 1, "b": 2}|reduce((c, v, k) => c + v, 0) }}' + }).renderAsync() + .then(output => { + output.should.equal('3'); + }); + }); + it('should default carry to null when no initial is passed', function () { + return twig({ + data: '{{ [1, 2]|reduce((c, v) => (c ?? 0) + v) }}' + }).renderAsync() + .then(output => { + output.should.equal('3'); + }); + }); + it('should render NaN when naive addition is used without an initial value because null plus a number yields NaN', function () { + return twig({ + data: '{{ [1, 2, 3]|reduce((c, v) => c + v) }}' + }).renderAsync() + .then(output => { + output.should.equal('NaN'); + }); + }); + it('should support string concatenation with initial empty string', function () { + return twig({ + data: '{{ ["a", "b", "c"]|reduce((carry, v) => carry ~ v, "") }}' + }).renderAsync() + .then(output => { + output.should.equal('abc'); + }); + }); + it('should pass null as carry on the first iteration when no initial value is given', function () { + return twig({ + data: '{{ [1]|reduce((carry, v) => carry is null) }}' + }).renderAsync() + .then(output => { + output.should.equal('true'); + }); + }); + it('should return the initial value when reducing an empty array', function () { + return twig({ + data: '{{ []|reduce((c, v) => c + v, 10) }}' + }).renderAsync() + .then(output => { + output.should.equal('10'); + }); + }); + it('should leave carry as null when reducing an empty array with no initial value', function () { + return twig({ + data: '{{ []|reduce((c, v) => c + v) }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should reduce a single-element array with an initial value', function () { + return twig({ + data: '{{ [42]|reduce((c, v) => c + v, 0) }}' + }).renderAsync() + .then(output => { + output.should.equal('42'); + }); + }); + it('should leave the initial carry unchanged for a number primitive (no enumerable keys, unlike filter/map)', function () { + return twig({ + data: '{{ 5|reduce((c, v) => c + v, 0) }}' + }).renderAsync() + .then(output => { + output.should.equal('0'); + }); + }); + it('should iterate string primitives by index because Object.keys exposes string characters', function () { + return twig({ + data: '{{ "ab"|reduce((carry, v) => carry ~ v, "") }}' + }).renderAsync() + .then(output => { + output.should.equal('ab'); + }); + }); + }); + + describe('sort (arrow) ->', function () { + it('should sort an array with a sync arrow comparator', function () { + return twig({ + data: '{{ [3, 1, 2]|sort((a, b) => a - b)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,2,3'); + }); + }); + it('should join to an empty string when sorting an empty array with an arrow comparator', function () { + return twig({ + data: '{{ []|sort((a, b) => a - b)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should not mutate the original array', function () { + return twig({ + data: '{% set arr = [2, 1] %}{{ arr|sort((a, b) => a - b)|join }}{{ arr|join }}' + }).renderAsync() + .then(output => { + output.should.equal('1221'); + }); + }); + it('should sort with an arrow that uses async function in renderAsync', function () { + Twig.extendFunction('asyncDoubleSort', x => Twig.Promise.resolve(x * 2)); + return twig({ + data: '{{ [2, 1, 3]|sort((a, b) => asyncDoubleSort(a) - asyncDoubleSort(b))|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,2,3'); + }); + }); + it('should keep default sort without arrow (regression)', function () { + return twig({ + data: '{{ [3, 1, 2]|sort|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,2,3'); + }); + }); + it('should sort with async filter in comparator (merge-sort path)', function () { + Twig.extendFilter('asyncDouble', v => Twig.Promise.resolve(v * 2)); + return twig({ + data: '{{ [3, 1, 2]|sort((a, b) => a|asyncDouble - b|asyncDouble)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,2,3'); + }); + }); + it('should sort arrays with duplicate values using a numeric comparator', function () { + return twig({ + data: '{{ [2, 1, 1, 3]|sort((a, b) => a - b)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,1,2,3'); + }); + }); + it('should sort an array of objects by a numeric field using an arrow comparator', function () { + return twig({ + data: '{{ [{"score": 3}, {"score": 1}, {"score": 2}]|sort((a, b) => a.score - b.score)|map(o => o.score)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('1,2,3'); + }); + }); + }); + + describe('find (arrow) ->', function () { + it('should return the first matching value in an array', function () { + return twig({ + data: '{{ [1, 5, 2, 7]|find(v => v > 3) }}' + }).renderAsync() + .then(output => { + output.should.equal('5'); + }); + }); + it('should return the first match by key for arrays', function () { + return twig({ + data: '{{ ["x", "y", "z"]|find((v, k) => k >= 2) }}' + }).renderAsync() + .then(output => { + output.should.equal('z'); + }); + }); + it('should return the first matching value in an object', function () { + return twig({ + data: '{{ {"a": 1, "b": 5, "c": 2}|find((v, k) => v > 3) }}' + }).renderAsync() + .then(output => { + output.should.equal('5'); + }); + }); + it('should return nothing when no element matches', function () { + return twig({ + data: '{{ [1, 2]|find(v => v > 9) }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should render an empty string when the array is empty', function () { + return twig({ + data: '{{ []|find(v => true) }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should render an empty string when the object is empty', function () { + return twig({ + data: '{{ {}|find(v => true) }}' + }).renderAsync() + .then(output => { + output.should.equal(''); + }); + }); + it('should pass through a scalar string when the value is not an array or plain object', function () { + return twig({ + data: '{{ "x"|find(v => true) }}' + }).renderAsync() + .then(output => { + output.should.equal('x'); + }); + }); + }); + + describe('arrow filters — integration ->', function () { + it('should support parenthesized filter arguments beside arrows', function () { + return twig({ + data: '{{ items|slice(0, (n - 1))|join(",") }}' + }).renderAsync({items: [1, 2, 3, 4, 5], n: 4}) + .then(output => { + output.should.equal('1,2,3'); + }); + }); + it('should chain filter, map, sort, and join', function () { + return twig({ + data: '{{ [5, 1, 4, 2, 3]|filter(v => v > 0)|map(v => v * 2)|sort((a, b) => a - b)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('2,4,6,8,10'); + }); + }); + it('should throw when sync render uses an async filter inside an arrow body', function () { + Twig.extendFilter('asyncDoubleNative', v => Promise.resolve(v * 2)); + try { + twig({ + data: '{{ [1]|filter(v => v|asyncDoubleNative > 0) }}' + }).render(); + throw new Error('should have thrown an error.'); + } catch (error) { + error.message.should.equal('You are using Twig.js in sync mode in combination with async extensions.'); + } + }); + it('should render synchronously when a filter returns Twig.Promise inside an arrow body, unlike native Promise', function () { + Twig.extendFilter('asyncTwigPromise', v => Twig.Promise.resolve(v * 2)); + twig({ + data: '{{ [1]|filter(v => v|asyncTwigPromise > 0) }}' + }).render().should.equal('1'); + }); + it('should chain two async filters used inside arrow bodies (renderAsync)', function () { + Twig.extendFilter('asyncAdd10', v => Twig.Promise.resolve(v + 10)); + Twig.extendFilter('asyncMul3', v => Twig.Promise.resolve(v * 3)); + return twig({ + data: '{{ [1, 2]|map(v => v|asyncAdd10)|filter(v => v|asyncMul3 > 35)|join(",") }}' + }).renderAsync() + .then(output => { + output.should.equal('12'); + }); + }); + }); + // Other describe('default ->', function () { it('should not provide the default value if a key is defined and not empty', function () {