Resolve issue #595

This commit is contained in:
Eric MORAND 2023-12-03 20:04:00 +00:00
parent 6353ead10c
commit faeab75abe
13 changed files with 332 additions and 28 deletions

View File

@ -68,13 +68,14 @@ Twing's strategy here is to stick strictly to Semantic Versioning rules and *nev
Here is the compatibility chart between minor versions of Twing and Twig specifications levels, along with a summary of notable features provided by each Twig specifications level. Note that Twig minor versions don't always provide new language-related features (because of Twig's team perpetuating the confusion between Twig and their reference implementation, TwigPHP).
|Twing version|Twig specifications level|Notable features|
|:---:|:---:|---|
|3.0|2.11|[Macros scoping](https://twig.symfony.com/doc/2.x/tags/macro.html#macros-scoping)|
|2.3|2.10|`spaceless`, `column`, `filter`, `map` and `reduce` filters, `apply` tag, `line whitespace trimming` whitespace control modifier|
|2.2|2.6|`deprecated` tag|
|1.3|2.5|`spaceless` and `block`-related deprecations|
|1.0|2.4| |
| Twing version | Twig specifications level | Notable features |
|:-------------:|:-------------------------:|----------------------------------------------------------------------------------------------------------------------------------|
| 5.2 | 2.14 | `spaceship` operator, `sort` filter comparator`, hash “short” syntax |
| 3.0 | 2.11 | [Macros scoping](https://twig.symfony.com/doc/2.x/tags/macro.html#macros-scoping) |
| 2.3 | 2.10 | `spaceless`, `column`, `filter`, `map` and `reduce` filters, `apply` tag, `line whitespace trimming` whitespace control modifier |
| 2.2 | 2.6 | `deprecated` tag |
| 1.3 | 2.5 | `spaceless` and `block`-related deprecations |
| 1.0 | 2.4 | |
It is highly recommended to always use the latest version of Twing available as bug fixes will always target the latest version.

View File

@ -127,6 +127,7 @@ import {TwingNodeVisitorMacroAutoImport} from "../node-visitor/macro-auto-import
import {TwingTokenParserLine} from "../token-parser/line";
import {extname, basename} from "path";
import {TwingEscapingStrategyResolver} from "../environment";
import {TwingNodeExpressionBinarySpaceship} from "../node/expression/binary/spaceship";
export class TwingExtensionCore extends TwingExtension {
private dateFormats: Array<string> = ['F j, Y H:i', '%d days'];
@ -432,7 +433,10 @@ export class TwingExtensionCore extends TwingExtension {
{name: 'length', defaultValue: null},
{name: 'preserve_keys', defaultValue: false}
]),
new TwingFilter('sort', sort, []),
new TwingFilter('sort', sort, [{
name: 'arrow',
defaultValue: null
}]),
new TwingFilter('spaceless', spaceless, [], {
is_safe: ['html']
}),
@ -571,6 +575,9 @@ export class TwingExtensionCore extends TwingExtension {
new TwingOperator('!=', TwingOperatorType.BINARY, 20, function (operands: [TwingNode, TwingNode], lineno: number, columnno: number) {
return new TwingNodeExpressionBinaryNotEqual(operands, lineno, columnno);
}),
new TwingOperator('<=>', TwingOperatorType.BINARY, 20, (operands: [TwingNode, TwingNode], line: number, column: number) => {
return new TwingNodeExpressionBinarySpaceship(operands, line, column);
}),
new TwingOperator('<', TwingOperatorType.BINARY, 20, function (operands: [TwingNode, TwingNode], lineno: number, columnno: number) {
return new TwingNodeExpressionBinaryLess(operands, lineno, columnno);
}),

View File

@ -10,14 +10,17 @@ import {asort} from "../../../helpers/asort";
*
* @returns {Promise<Map<any, any>>}
*/
export function sort(iterable: Map<any, any>): Promise<Map<any, any>> {
export async function sort(
iterable: Map<any, any>,
arrow: ((a: any, b: any) => Promise<-1 | 0 | 1>) | null
): Promise<Map<any, any>> {
if (!isTraversable(iterable)) {
throw new TwingErrorRuntime(`The sort filter only works with iterables, got "${typeof iterable}".`);
}
let map = iteratorToMap(iterable);
asort(map);
await asort(map, arrow || undefined);
return Promise.resolve(map);
}

View File

@ -1,3 +1,5 @@
import {sortAsynchronously} from "./sort";
/**
* Sort a map and maintain index association.
*
@ -5,25 +7,33 @@
* @param {Function} handler
* @returns {Map<* ,*>}
*/
export function asort(map: Map<any, any>, handler: any = undefined) {
let sortedMap = new Map();
export async function asort(map: Map<any, any>, compareFunction?: (a: any, b: any) => Promise<-1 | 0 | 1>) {
const sortedMap = new Map();
const keys: Array<any> = ([] as Array<any>).fill(null, 0, map.size);
const values = [...map.values()];
let keys: Array<any> = [].fill(null, 0, map.size);
let sortedValues = [...map.values()].sort(handler);
let sortedValues: Array<any>;
for (let [key, value] of map) {
let index = sortedValues.indexOf(value);
if (compareFunction) {
sortedValues = await sortAsynchronously(values, compareFunction);
}
else {
sortedValues = values.sort();
}
for (const [key, value] of map) {
const index = sortedValues.indexOf(value);
keys[index] = key;
}
for (let key of keys) {
for (const key of keys) {
sortedMap.set(key, map.get(key));
}
map.clear();
for (let [key, value] of sortedMap) {
for (const [key, value] of sortedMap) {
map.set(key, value);
}
}

77
src/lib/helpers/sort.ts Normal file
View File

@ -0,0 +1,77 @@
type Comparator = (a: any, b: any) => Promise<-1 | 0 | 1>;
export const sortAsynchronously = (array: Array<any>, comparator: Comparator) => {
/**
* return the median value among x, y, and z
*/
const getPivot = async (x: any, y: any, z: any, comparator: Comparator): Promise<any> => {
if (await comparator(x, y) < 0) {
if (await comparator(y, z) < 0) {
return y;
}
else if (await comparator(z, x) < 0) {
return x;
}
else {
return z;
}
}
else if (await comparator(y, z) > 0) {
return y;
}
else if (await comparator(z, x) > 0) {
return x;
}
else {
return z;
}
};
/**
* Asynchronous quick sort.
*
* @see https://gist.github.com/kimamula/fa34190db624239111bbe0deba72a6ab
*
* @param array The array to sort
* @param comparator The comparator function
* @param left The index where the range of elements to be sorted starts
* @param right The index where the range of elements to be sorted ends
*/
const quickSort = async (array: Array<any>, comparator: Comparator, left = 0, right = array.length - 1): Promise<Array<any>> => {
if (left < right) {
let i = left;
let j = right;
let tmp;
const pivot = await getPivot(array[i], array[i + Math.floor((j - i) / 2)], array[j], comparator);
while (true) {
while (await comparator(array[i], pivot) < 0) {
i++;
}
while (await comparator(pivot, array[j]) < 0) {
j--;
}
if (i >= j) {
break;
}
tmp = array[i];
array[i] = array[j];
array[j] = tmp;
i++;
j--;
}
await quickSort(array, comparator, left, i - 1);
await quickSort(array, comparator, j + 1, right);
}
return array;
};
return quickSort(array, comparator);
};

View File

@ -0,0 +1,25 @@
import {TwingNodeExpressionBinary} from "../binary";
import {TwingCompiler} from "../../../compiler";
import {TwingNodeType} from "../../../node-type";
export const type = new TwingNodeType('expression_spaceship');
export class TwingNodeExpressionBinarySpaceship extends TwingNodeExpressionBinary {
get type() {
return type;
}
compile(compiler: TwingCompiler) {
compiler
.raw('this.compare(')
.subcompile(this.getNode('left'))
.raw(', ')
.subcompile(this.getNode('right'))
.raw(') ? 0 : (')
.subcompile(this.getNode('left'))
.raw(' < ')
.subcompile(this.getNode('right'))
.raw(' ? -1 : 1)')
;
}
}

View File

@ -851,7 +851,17 @@ export class TwingParser {
let token;
let key;
if ((token = stream.nextIf(TokenType.STRING)) || (token = stream.nextIf(TokenType.NAME)) || (token = stream.nextIf(TokenType.NUMBER))) {
if (token = stream.nextIf(TokenType.NAME)) {
key = new TwingNodeExpressionConstant(token.value, token.line, token.column);
// {a} is a shortcut for {a:a}
if (stream.test(TokenType.PUNCTUATION, [',', '}'])) {
node.addElement(new TwingNodeExpressionName(token.value, token.line, token.column), key);
continue;
}
}
else if ((token = stream.nextIf(TokenType.STRING)) || (token = stream.nextIf(TokenType.NUMBER))) {
key = new TwingNodeExpressionConstant(token.value, token.line, token.column);
} else if (stream.test(TokenType.PUNCTUATION, '(')) {
key = this.parseExpression();

View File

@ -0,0 +1,21 @@
import TestBase from "../../TestBase";
export default class extends TestBase {
getDescription() {
return 'Hash key can be omitted if it is the same as the variable name';
}
getTemplates() {
return {
'index.twig': `
{% set foo = "foo" %}
{% set hash = { foo, bar: "bar" } %}
{{ hash|join }}`
};
}
getExpected() {
return `
foobar`;
}
}

View File

@ -0,0 +1,17 @@
import TestBase from "../../TestBase";
export default class extends TestBase {
getDescription() {
return '"sort" filter with arrow parameter';
}
getTemplates() {
return {
'index.twig': `{{ [5, 3, 4]|sort((a, b) => b <=> a)|join() }}`
};
}
getExpected() {
return `543`;
}
}

View File

@ -0,0 +1,124 @@
import TestBase from "../../TestBase";
export default class extends TestBase {
getDescription() {
return '"spaceship" operator';
}
getTemplates() {
return {
'index.twig': `
{% set nullVar = null %}
{% set emptyArray = [] %}
{{ false <=> 0 }}
{{ false <=> '' }}
{{ false <=> '0' }}
{{ false <=> nullVar }}
{{ false <=> emptyArray }}
1
{{ 1 <=> true }}
{{ 1 <=> '1' }}
0
{{ 0 <=> false }}
{{ 0 <=> '0' }}
{{ 0 <=> null }}
-1
{{ -1 <=> true }}
{{ -1 <=> '-1' }}
"1"
{{ '1' <=> true }}
{{ '1' <=> 1 }}
"0"
{{ '0' <=> false }}
{{ '0' <=> 0 }}
"-1"
{{ '-1' <=> true }}
{{ '-1' <=> -1 }}
null
{{ nullVar <=> false }}
{{ nullVar <=> 0 }}
{{ nullVar <=> emptyArray }}
{{ nullVar <=> '' }}
[]
{{ emptyArray <=> false }}
{{ emptyArray <=> nullVar }}
"php"
{{ 'php' <=> true }}
{{ 'php' <=> 0 }}
""
{{ '' <=> false }}
{{ '' <=> 0 }}
{{ '' <=> nullVar }}
{{ 1 <=> 2 }}
`
};
}
getExpected() {
return `
0
0
0
0
0
1
0
0
0
0
0
0
-1
0
0
"1"
0
0
"0"
0
0
"-1"
0
0
null
0
0
0
0
[]
0
0
"php"
0
0
""
0
0
0
-1
`;
}
}

View File

@ -3,11 +3,10 @@ import {sort} from "../../../../../../../../src/lib/extension/core/filters/sort"
tape('sort', async (test) => {
try {
await sort(5 as any);
await sort(5 as any, () => Promise.resolve(-1));
test.fail();
}
catch (e) {
} catch (e) {
test.same(e.message, 'The sort filter only works with iterables, got "number".');
}

View File

@ -74,10 +74,14 @@ tape('TwingExtensionCore', (test) => {
* @param {TwingFunction} f
* @param fixture
*/
const testAcceptedArguments = (test: tape.Test, name: string, f: TwingFunction, fixture: { name: string, arguments: TwingCallableArgument[] }) => {
const testAcceptedArguments = (test: tape.Test, name: string, f: TwingFunction, fixture: {
name: string,
arguments: TwingCallableArgument[]
}) => {
if (!fixture) {
test.fail(`${name} function has no registered fixture`);
} else {
}
else {
test.same(f.getAcceptedArgments(), fixture.arguments, `${name} function accepted arguments are as expected`);
}
};
@ -167,10 +171,14 @@ tape('TwingExtensionCore', (test) => {
* @param {TwingFilter} f
* @param fixture
*/
const testAcceptedArguments = (test: tape.Test, name: string, f: TwingFilter, fixture: { name: string, arguments: TwingCallableArgument[] }) => {
const testAcceptedArguments = (test: tape.Test, name: string, f: TwingFilter, fixture: {
name: string,
arguments: TwingCallableArgument[]
}) => {
if (!fixture) {
test.fail(`${name} filter has no registered fixture`);
} else {
}
else {
test.same(f.getAcceptedArgments(), fixture.arguments, `${name} filter accepted arguments are as expected`);
}
};
@ -344,7 +352,9 @@ tape('TwingExtensionCore', (test) => {
},
{
name: 'sort',
arguments: []
arguments: [
{name: 'arrow', defaultValue: null}
]
},
{
name: 'spaceless',

View File

@ -16,7 +16,7 @@ tape('asort', (test) => {
let map = new Map([[1, 'foo'], [0, 'bar']]);
asort(map, (a: any, b: any) => {
return (a > b) ? -1 : 1;
return Promise.resolve((a > b) ? -1 : 1);
});
test.same([...map.values()], ['foo', 'bar']);