Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eb70dbd
feat(arrow-functions): Add support for arrow operator and arrow funct…
michaeldk Mar 26, 2026
c9e1776
feat(arrow-functions): Implement arrow function preprocessing in Twig…
michaeldk Mar 26, 2026
5b368ca
feat(arrow-functions): Add async evaluation for compiled arrow tokens…
michaeldk Mar 26, 2026
1851c17
feat(arrow-functions): Implement filter function for arrays and objec…
michaeldk Mar 26, 2026
a35c938
feat(arrow-functions): Add map filter for arrays and objects with arr…
michaeldk Mar 26, 2026
8424c58
feat(reduce-filter): Implement reduce filter for arrays and objects w…
michaeldk Mar 26, 2026
67a0cf3
feat(sort-filter): Enhance sort filter to support async arrow functio…
michaeldk Mar 26, 2026
2788fc8
feat(find-filter): Add find filter to retrieve the first matching val…
michaeldk Mar 26, 2026
70b7165
feat(arrow-functions): Add various edge cases tests
michaeldk Mar 27, 2026
7e83185
test(filters): Add tests for scalar values in filter, map, reduce, an…
michaeldk Mar 28, 2026
b91e5fb
test(filters): Add tests for reducing and sorting empty arrays and si…
michaeldk Mar 28, 2026
92d8b07
test(filters): Add tests for error handling in arrow functions with m…
michaeldk Mar 28, 2026
e68ac1d
test(filters): Add tests for sorting arrays with duplicates and chain…
michaeldk Mar 28, 2026
7fccd21
test(expressions): Add tests for arrow functions with invalid syntax
michaeldk Mar 28, 2026
12ffc35
test(filters): Update async filter tests
michaeldk Mar 28, 2026
1876039
test(filters): Refactor reduce filter test
michaeldk Mar 28, 2026
82e6a93
test(filters): Add tests for find filter with empty arrays and objects
michaeldk Mar 28, 2026
80185a5
test(filters): Add test for sorting an array of objects by a numeric …
michaeldk Mar 28, 2026
1b6693e
test(filters): Add test for NaN output when reducing an array with no…
michaeldk Mar 28, 2026
645487e
test(filters): Add test for synchronous rendering with async filter r…
michaeldk Mar 28, 2026
e17c9f1
test(filters): Add test for compiling arrow map in set expressions an…
michaeldk Mar 28, 2026
74ea3cc
refactor(arrow-functions): Better comments
michaeldk Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 181 additions & 6 deletions src/twig.expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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+)/,
Expand Down Expand Up @@ -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.
*
Expand All @@ -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 = [];
Expand Down
Loading