Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/en/reference/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,5 @@ Example syntax: <span v-pre>`{{ expression operator expression }}`</span>
- Logic (`and`, `or`, `not`, `()`): Supported
- Bitwise (`b-and`, `b-or`, `b-xor`): Supported
- Comparisons (`==`, `!=`, `<`, `>`, `>=`, `<=`, `===`): Supported
- Others (`..`, `|`, `~`, `.`, `[]`, `?:`): Supported
- Null-coalescing (`??`): Supported
- Others (`..`, `|`, `~`, `.`, `?.`, `[]`, `?:`): Supported
- Null-coalescing (`??`): Supported
117 changes: 80 additions & 37 deletions src/twig.expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
module.exports = function (Twig) {
'use strict';

function isSafeAccess(value) {
return value != null; // Use != null to check for both null and undefined
}

function normalizeObject(object) {
return object === null ? null : Object(object);
}

function parseParams(state, params, context) {
if (params) {
return Twig.expression.parseAsync.call(state, params, context);
Expand Down Expand Up @@ -214,7 +222,7 @@ module.exports = function (Twig) {
type: Twig.expression.type.operator.binary,
// Match any of ??, ?:, +, *, /, -, %, ~, <=>, <, <=, >, >=, !=, ==, **, ?, :, and, b-and, or, b-or, b-xor, in, not in
// and, or, in, not in, matches, starts with, ends with can be followed by a space or parenthesis
regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%?]|^(<=>)|^[:](?!\d\])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/,
regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%]|^\?(?![.\[])|^(<=>)|^[:](?!\d\])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/,
next: Twig.expression.set.expressions,
transform(match, tokens) {
switch (match[0]) {
Expand Down Expand Up @@ -488,14 +496,20 @@ module.exports = function (Twig) {
* Match a parameter set start.
*/
type: Twig.expression.type.parameter.start,
regex: /^\(/,
regex: /^(\?\.)?\(/,
next: Twig.expression.set.expressions.concat([Twig.expression.type.parameter.end]),
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
return lastToken && (!Twig.expression.reservedWords.includes(lastToken.value.trim()));
},
compile: Twig.expression.fn.compile.pushBoth,
compile(token, stack, output) {
token.optionalCall = token.match[1] === '?.';
token.value = '(';
delete token.match;
output.push(token);
stack.push(token);
},
parse: Twig.expression.fn.parse.push
},
{
Expand Down Expand Up @@ -523,6 +537,7 @@ module.exports = function (Twig) {
token = output.pop();
}

endToken.optionalCall = token.optionalCall;
paramStack.unshift(token);

// Get the token preceding the parameters
Expand Down Expand Up @@ -820,8 +835,7 @@ module.exports = function (Twig) {
return '(';
},
compile(token, stack, output) {
const fn = token.match[1];
token.fn = fn;
token.fn = token.match[1];
// Cleanup token
delete token.match;
delete token.value;
Expand Down Expand Up @@ -872,28 +886,38 @@ module.exports = function (Twig) {
validate(match) {
return (!Twig.expression.reservedWords.includes(match[0]));
},
parse(token, stack, context) {
parse(token, stack, context, nextToken) {
const state = this;

// Get the variable from the context
return Twig.expression.resolveAsync.call(state, context[token.value], context)
.then(value => {
if (state.template.options.strictVariables && value === undefined) {
const isOptionalChain = nextToken &&
(nextToken.type === Twig.expression.type.key.period ||
nextToken.type === Twig.expression.type.key.brackets) &&
nextToken.optional;

if (state.template.options.strictVariables && value === undefined && !isOptionalChain) {
throw new Twig.Error('Variable "' + token.value + '" does not exist.');
}

stack.push(value);
if (isOptionalChain && !isSafeAccess(value)) {
stack.push(undefined);
} else {
stack.push(value);
}
});
}
},
{
type: Twig.expression.type.key.period,
regex: /^\.(\w+)/,
regex: /^(\?\.|\.)(\w+)/,
next: Twig.expression.set.operationsExtended.concat([
Twig.expression.type.parameter.start
]),
compile(token, stack, output) {
token.key = token.match[1];
token.optional = token.match[1] === '?.';
token.key = token.match[2];
delete token.match;
delete token.value;

Expand All @@ -903,36 +927,41 @@ module.exports = function (Twig) {
const state = this;
const {key} = token;
const object = stack.pop();
const normalizedObject = normalizeObject(object);
let value;

if (object && !Object.prototype.hasOwnProperty.call(object, key) && state.template.options.strictVariables) {
const keys = Object.keys(object);
if (token.optional && !isSafeAccess(object)) {
stack.push(undefined);
return;
}

if (normalizedObject && !(key in normalizedObject) &&
!normalizedObject['get' + key.slice(0, 1).toUpperCase() + key.slice(1)] &&
!normalizedObject['is' + key.slice(0, 1).toUpperCase() + key.slice(1)] &&
state.template.options.strictVariables) {
const keys = Object.keys(normalizedObject);
if (keys.length > 0) {
throw new Twig.Error('Key "' + key + '" for object with keys "' + Object.keys(object).join(', ') + '" does not exist.');
throw new Twig.Error('Key "' + key + '" for object with keys "' + keys.join(', ') + '" does not exist.');
} else {
throw new Twig.Error('Key "' + key + '" does not exist as the object is empty.');
}
}

return parseParams(state, token.params, context)
.then(params => {
if (object === null || object === undefined) {
value = undefined;
const capitalize = function (value) {
return value.slice(0, 1).toUpperCase() + value.slice(1);
};

// Get the variable from the context
if (key in normalizedObject) {
value = normalizedObject[key];
} else if (normalizedObject['get' + capitalize(key)]) {
value = normalizedObject['get' + capitalize(key)];
} else if (normalizedObject['is' + capitalize(key)]) {
value = normalizedObject['is' + capitalize(key)];
} else {
const capitalize = function (value) {
return value.slice(0, 1).toUpperCase() + value.slice(1);
};

// Get the variable from the context
if (typeof object === 'object' && key in object) {
value = object[key];
} else if (object['get' + capitalize(key)]) {
value = object['get' + capitalize(key)];
} else if (object['is' + capitalize(key)]) {
value = object['is' + capitalize(key)];
} else {
value = undefined;
}
value = undefined;
}

// When resolving an expression we need to pass nextToken in case the expression is a function
Expand All @@ -945,12 +974,13 @@ module.exports = function (Twig) {
},
{
type: Twig.expression.type.key.brackets,
regex: /^\[([^\]]*)\]/,
regex: /^(\?\.)?\[([^\]]*)]/,
next: Twig.expression.set.operationsExtended.concat([
Twig.expression.type.parameter.start
]),
compile(token, stack, output) {
const match = token.match[1];
const match = token.match[2];
token.optional = token.match[1] === '?.';
delete token.value;
delete token.match;

Expand All @@ -975,21 +1005,26 @@ module.exports = function (Twig) {
})
.then(key => {
object = stack.pop();
const normalizedObject = normalizeObject(object);

// For optional chaining, short-circuit on null/undefined
if (token.optional && !isSafeAccess(object)) {
stack.push(undefined);
return;
}

if (object && !Object.prototype.hasOwnProperty.call(object, key) && state.template.options.strictVariables) {
const keys = Object.keys(object);
if (normalizedObject && !(key in normalizedObject) && state.template.options.strictVariables) {
const keys = Object.keys(normalizedObject);
if (keys.length > 0) {
throw new Twig.Error('Key "' + key + '" for array with keys "' + keys.join(', ') + '" does not exist.');
} else {
throw new Twig.Error('Key "' + key + '" does not exist as the array is empty.');
}
} else if (object === null || object === undefined) {
return null;
}

// Get the variable from the context
if (typeof object === 'object' && key in object) {
value = object[key];
if (key in normalizedObject) {
value = normalizedObject[key];
} else {
value = null;
}
Expand Down Expand Up @@ -1062,6 +1097,14 @@ module.exports = function (Twig) {
return Twig.Promise.resolve(value);
}

// Handle optional chaining for method calls
if (nextToken && nextToken.type === Twig.expression.type.parameter.end && nextToken.optionalCall) {
// For optional calls, only return undefined if the object is null/undefined
if (!isSafeAccess(object)) {
return Twig.Promise.resolve(undefined);
}
}

let promise = Twig.Promise.resolve(params);

/*
Expand Down
Loading