From 1537c6e5310bafdb14c4f8cd788e306313141aa9 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 22 Nov 2023 03:12:03 +0100 Subject: [PATCH 1/9] filter |sort accepts iterable --- src/Latte/Essential/Filters.php | 25 ++++++++++++----- tests/filters/sort.phpt | 48 ++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index fb1419be5..8b1fcacbc 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -449,14 +449,27 @@ public static function batch(iterable $list, int $length, $rest = null): \Genera /** - * Sorts an array. - * @param mixed[] $array - * @return mixed[] + * Sorts elements using the comparison function and preserves the key association. */ - public static function sort(array $array, ?\Closure $callback = null): array + public static function sort(iterable $iterable, ?\Closure $comparison = null): iterable { - $callback ? uasort($array, $callback) : asort($array); - return $array; + if (is_array($iterable)) { + $comparison ? uasort($iterable, $comparison) : asort($iterable); + return $iterable; + } + + $keys = $values = []; + foreach ($iterable as $key => $value) { + $keys[] = $key; + $values[] = $value; + } + $comparison ? uasort($values, $comparison) : asort($values); + + return (static function () use ($keys, $values): \Generator { + foreach ($values as $i => $value) { + yield $keys[$i] => $value; + } + })(); } diff --git a/tests/filters/sort.phpt b/tests/filters/sort.phpt index bfd246b40..99d62cd6f 100644 --- a/tests/filters/sort.phpt +++ b/tests/filters/sort.phpt @@ -12,7 +12,49 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -Assert::same([1 => 10, 0 => 20, 30], Filters::sort([20, 10, 30])); -Assert::same([], Filters::sort([])); +function iterator(): Generator +{ + yield 'a' => 20; + yield 'b' => 10; + yield [true] => 30; +} -Assert::same([2 => 30, 0 => 20, 1 => 10], Filters::sort([20, 10, 30], fn($a, $b) => $b <=> $a)); + +function exportIterator(Traversable $iterator): array +{ + $res = []; + foreach ($iterator as $key => $value) { + $res[] = [$key, $value]; + } + return $res; +} + + +test('array', function () { + Assert::same([1 => 10, 0 => 20, 30], Filters::sort([20, 10, 30])); + Assert::same([], Filters::sort([])); +}); + + +test('iterator', function () { + Assert::same( + [['b', 10], ['a', 20], [[true], 30]], + exportIterator(Filters::sort(iterator())), + ); +}); + + +test('user comparison + array', function () { + Assert::same( + [2 => 30, 0 => 20, 1 => 10], + Filters::sort([20, 10, 30], fn($a, $b) => $b <=> $a) + ); +}); + + +test('user comparison + iterator', function () { + Assert::same( + [[[true], 30], ['a', 20], ['b', 10]], + exportIterator(Filters::sort(iterator(), fn($a, $b) => $b <=> $a)), + ); +}); From a9a852199e8773dec6ea9add2814a9579f6a2129 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 5 Dec 2023 15:37:42 +0100 Subject: [PATCH 2/9] added filter |group --- src/Latte/Essential/CoreExtension.php | 1 + src/Latte/Essential/Filters.php | 32 ++++++++++ tests/filters/group.phpt | 88 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/filters/group.phpt diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index 0760d87c4..1a4980f7b 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -133,6 +133,7 @@ public function getFilters(): array ? [$this->filters, 'firstUpper'] : fn() => throw new RuntimeException('Filter |firstUpper requires mbstring extension.'), 'floor' => [$this->filters, 'floor'], + 'group' => [$this->filters, 'group'], 'implode' => [$this->filters, 'implode'], 'indent' => [$this->filters, 'indent'], 'join' => [$this->filters, 'implode'], diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 8b1fcacbc..0705bcdf5 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -473,6 +473,38 @@ public static function sort(iterable $iterable, ?\Closure $comparison = null): i } + /** + * Groups elements by the element indices and preserves the key association and order. + */ + public static function group(iterable $iterable, string|int|\Closure $by): \Generator + { + $fn = $by instanceof \Closure ? $by : fn($a) => is_array($a) ? $a[$by] : $a->$by; + $keys = $groups = $prevKey = []; + + foreach ($iterable as $k => $v) { + $groupKey = $fn($v, $k); + if (!$groups || $prevKey !== $groupKey) { + $index = array_search($groupKey, $keys, true); + if ($index === false) { + $index = count($keys); + $keys[$index] = $groupKey; + } + $prevKey = $groupKey; + } + $groups[$index][0][] = $k; + $groups[$index][1][] = $v; + } + + foreach ($groups as $index => $pair) { + yield $keys[$index] => (static function () use ($pair): \Generator { + foreach ($pair[1] as $i => $value) { + yield $pair[0][$i] => $value; + } + })(); + } + } + + /** * Returns value clamped to the inclusive range of min and max. */ diff --git a/tests/filters/group.phpt b/tests/filters/group.phpt new file mode 100644 index 000000000..ac86d3685 --- /dev/null +++ b/tests/filters/group.phpt @@ -0,0 +1,88 @@ + 55] => ['k' => 22, 'k2']; + yield ['a' => 66] => (object) ['k' => 22, 'k2']; + yield ['a' => 77] => ['k' => 11]; + yield ['a' => 88] => ['k' => 33]; +} + + +function exportIterator(Traversable $iterator): array +{ + $res = []; + foreach ($iterator as $key => $value) { + $res[] = [$key, $value instanceof Traversable ? exportIterator($value) : $value]; + } + return $res; +} + + +test('array', function () { + Assert::equal( + [ + [22, [ + [0, ['k' => 22, 'k2']], + [1, (object) ['k' => 22, 'k2']], + ]], + [11, [[2, ['k' => 11]]]], + [33, [[3, ['k' => 33]]]], + ], + exportIterator(Filters::group( + [['k' => 22, 'k2'], (object) ['k' => 22, 'k2'], ['k' => 11], ['k' => 33]], + 'k', + )), + ); + Assert::same([], exportIterator(Filters::group([], 'k'))); +}); + + +test('iterator', function () { + Assert::equal( + [ + [22, [ + [['a' => 55], ['k' => 22, 'k2']], + [['a' => 66], (object) ['k' => 22, 'k2']], + ]], + [11, [[['a' => 77], ['k' => 11]]]], + [33, [[['a' => 88], ['k' => 33]]]], + ], + exportIterator(Filters::group(iterator(), 'k')), + ); +}); + + +test('array + callback', function () { + Assert::same( + [[220, [[0, 22]]], [110, [[1, 11]]], [330, [[2, 33]]]], + exportIterator(Filters::group([22, 11, 33], fn($a) => $a * 10)), + ); +}); + + +test('iterator + callback', function () { + Assert::equal( + [ + [-22, [ + [['a' => 55], ['k' => 22, 'k2']], + [['a' => 66], (object) ['k' => 22, 'k2']], + ]], + [-11, [[['a' => 77], ['k' => 11]]]], + [-33, [[['a' => 88], ['k' => 33]]]], + ], + exportIterator(Filters::group(iterator(), fn($a) => -((array) $a)['k'])), + ); +}); From b4807f2f6ef774adb946da1b174e1e85171a9101 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 29 Apr 2024 22:19:20 +0200 Subject: [PATCH 3/9] Released version 3.0.15 --- src/Latte/Engine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index f3d618ab5..34f16e4b9 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -17,8 +17,8 @@ */ class Engine { - public const Version = '3.0.14'; - public const VersionId = 30014; + public const Version = '3.0.15'; + public const VersionId = 30015; /** @deprecated use Engine::Version */ public const From b1c4e765d68e34a9491ce1168a42564f3e6d3590 Mon Sep 17 00:00:00 2001 From: machina86 Date: Thu, 2 May 2024 12:59:02 -0700 Subject: [PATCH 4/9] add ability to set default syntax from Latte\Engine --- src/Latte/Compiler/TemplateParser.php | 10 ++++++++++ src/Latte/Engine.php | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Latte/Compiler/TemplateParser.php b/src/Latte/Compiler/TemplateParser.php index 2dd8eb9f6..fe2ef0e23 100644 --- a/src/Latte/Compiler/TemplateParser.php +++ b/src/Latte/Compiler/TemplateParser.php @@ -408,6 +408,16 @@ public function getContentType(): string return $this->contentType; } + + /** + * Sets tag syntax for lexer + */ + public function setSyntax(?string $syntax): static + { + $this->lexer->setSyntax($syntax); + return $this; + } + /** @internal */ public function getStream(): TokenStream diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index 34f16e4b9..659209739 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -50,6 +50,7 @@ class Engine private bool $sandboxed = false; private ?string $phpBinary = null; private ?string $cacheKey; + private ?string $defaultSyntax = null; public function __construct() @@ -147,6 +148,9 @@ public function compile(string $name): string public function parse(string $template): TemplateNode { $parser = new Compiler\TemplateParser; + if ($this->defaultSyntax) { + $parser->setSyntax($this->defaultSyntax); + } $parser->strict = $this->strictParsing; foreach ($this->extensions as $extension) { @@ -563,6 +567,15 @@ public function isStrictParsing(): bool { return $this->strictParsing; } + + /** + * Sets default tag syntax + */ + public function setDefaultSyntax(?string $defaultSyntax): static + { + $this->defaultSyntax = $defaultSyntax; + return $this; + } public function setLoader(Loader $loader): static From 9fd913d320c6be9c0005216147ad4d99d7751e54 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 5 Dec 2023 14:40:21 +0100 Subject: [PATCH 5/9] added support for locale, affects |date, |number, |bytes and |sort filters --- composer.json | 1 + src/Latte/Engine.php | 20 ++++++ src/Latte/Essential/CoreExtension.php | 8 ++- src/Latte/Essential/Filters.php | 89 +++++++++++++++++++++++---- tests/filters/bytes.phpt | 16 ++++- tests/filters/date.phpt | 73 +++++++++++++--------- tests/filters/number.phpt | 55 +++++++++++++++++ tests/filters/sort.phpt | 46 ++++++++------ 8 files changed, 246 insertions(+), 62 deletions(-) create mode 100644 tests/filters/number.phpt diff --git a/composer.json b/composer.json index 44d1bb1cf..a735d680a 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "ext-iconv": "to use filters |reverse, |substring", "ext-mbstring": "to use filters like lower, upper, capitalize, ...", "ext-fileinfo": "to use filter |datastream", + "ext-intl": "to use Latte\\Engine::setLocale()", "nette/utils": "to use filter |webalize", "nette/php-generator": "to use tag {templatePrint}" }, diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index 6f0b3d737..00f33d4f1 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -50,6 +50,7 @@ class Engine private bool $sandboxed = false; private ?string $phpBinary = null; private ?string $cacheKey; + private ?string $locale = null; public function __construct() @@ -565,6 +566,25 @@ public function isStrictParsing(): bool } + /** + * Sets locale for date and number formatting. See PHP intl extension. + */ + public function setLocale(?string $locale): static + { + if ($locale && !extension_loaded('intl')) { + throw new RuntimeException("Locate requires the 'intl' extension to be installed."); + } + $this->locale = $locale; + return $this; + } + + + public function getLocale(): ?string + { + return $this->locale; + } + + public function setLoader(Loader $loader): static { $this->loader = $loader; diff --git a/src/Latte/Essential/CoreExtension.php b/src/Latte/Essential/CoreExtension.php index e081d5d2d..04a56c589 100644 --- a/src/Latte/Essential/CoreExtension.php +++ b/src/Latte/Essential/CoreExtension.php @@ -40,6 +40,12 @@ public function beforeCompile(Latte\Engine $engine): void } + public function beforeRender(Runtime\Template $template): void + { + $this->filters->locale = $template->getEngine()->getLocale(); + } + + public function getTags(): array { return [ @@ -142,7 +148,7 @@ public function getFilters(): array 'lower' => extension_loaded('mbstring') ? [$this->filters, 'lower'] : fn() => throw new RuntimeException('Filter |lower requires mbstring extension.'), - 'number' => 'number_format', + 'number' => [$this->filters, 'number'], 'padLeft' => [$this->filters, 'padLeft'], 'padRight' => [$this->filters, 'padRight'], 'query' => [$this->filters, 'query'], diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 66d4cc5dc..2eb4eaec2 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -23,6 +23,9 @@ */ final class Filters { + public ?string $locale = null; + + /** * Converts HTML to plain text. */ @@ -166,16 +169,13 @@ public static function repeat(FilterInfo $info, $s, int $count): string /** * Date/time formatting. */ - public static function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string + public function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string { + $format ??= Latte\Runtime\Filters::$dateFormat; if ($time == null) { // intentionally == return null; - } - - $format ??= Latte\Runtime\Filters::$dateFormat; - if ($time instanceof \DateInterval) { + } elseif ($time instanceof \DateInterval) { return $time->format($format); - } elseif (is_numeric($time)) { $time = (new \DateTime)->setTimestamp((int) $time); } elseif (!$time instanceof \DateTimeInterface) { @@ -186,8 +186,23 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti if (PHP_VERSION_ID >= 80100) { trigger_error("Function strftime() used by filter |date is deprecated since PHP 8.1, use format without % characters like 'Y-m-d'.", E_USER_DEPRECATED); } - return @strftime($format, $time->format('U') + 0); + + } elseif (preg_match('#^(\+(short|medium|long|full))?(\+time(\+sec)?)?$#', '+' . $format, $m)) { + $formatter = new \IntlDateFormatter( + $this->getLocale('date'), + match ($m[2]) { + 'short' => \IntlDateFormatter::SHORT, + 'medium' => \IntlDateFormatter::MEDIUM, + 'long' => \IntlDateFormatter::LONG, + 'full' => \IntlDateFormatter::FULL, + '' => \IntlDateFormatter::NONE, + }, + isset($m[3]) ? (isset($m[4]) ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT) : \IntlDateFormatter::NONE, + ); + $res = $formatter->format($time); + $res = preg_replace('~(\d\.) ~', "\$1\u{a0}", $res); + return $res; } return $time->format($format); @@ -197,7 +212,7 @@ public static function date(string|int|\DateTimeInterface|\DateInterval|null $ti /** * Converts to human-readable file size. */ - public static function bytes(float $bytes, int $precision = 2): string + public function bytes(float $bytes, int $precision = 2): string { $bytes = round($bytes); $units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; @@ -209,7 +224,15 @@ public static function bytes(float $bytes, int $precision = 2): string $bytes /= 1024; } - return round($bytes, $precision) . ' ' . $unit; + if ($this->locale === null) { + $bytes = (string) round($bytes, $precision); + } else { + $formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL); + $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $precision); + $bytes = $formatter->format($bytes); + } + + return $bytes . ' ' . $unit; } @@ -455,7 +478,7 @@ public static function batch(iterable $list, int $length, $rest = null): \Genera * @param iterable $data * @return iterable */ - public static function sort( + public function sort( iterable $data, ?\Closure $comparison = null, string|int|\Closure|null $by = null, @@ -469,7 +492,16 @@ public static function sort( $by = $byKey === true ? null : $byKey; } - $comparison ??= fn($a, $b) => $a <=> $b; + if ($comparison) { + } elseif ($this->locale === null) { + $comparison = fn($a, $b) => $a <=> $b; + } else { + $collator = new \Collator($this->locale); + $comparison = fn($a, $b) => is_string($a) && is_string($b) + ? $collator->compare($a, $b) + : $a <=> $b; + } + $comparison = match (true) { $by === null => $comparison, $by instanceof \Closure => fn($a, $b) => $comparison($by($a), $by($b)), @@ -650,4 +682,39 @@ public static function random(string|array $values): mixed ? $values[array_rand($values, 1)] : null; } + + + /** + * Formats a number with grouped thousands and optionally decimal digits according to locale. + */ + public function number( + float $number, + string|int $patternOrDecimals = 0, + string $decimalSeparator = '.', + string $thousandsSeparator = ',', + ): string + { + if (is_int($patternOrDecimals) && $patternOrDecimals < 0) { + throw new Latte\RuntimeException("Filter |$name: number of decimal must not be negative"); + } elseif ($this->locale === null || func_num_args() > 2) { + return number_format($number, $patternOrDecimals, $decimalSeparator, $thousandsSeparator); + } + + $formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL); + if (is_string($patternOrDecimals)) { + $formatter->setPattern($patternOrDecimals); + } else { + $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $patternOrDecimals); + } + return $formatter->format($number); + } + + + private function getLocale(string $name): string + { + if ($this->locale === null) { + throw new Latte\RuntimeException("Filter |$name requires the locale to be set using Engine::setLocale()"); + } + return $this->locale; + } } diff --git a/tests/filters/bytes.phpt b/tests/filters/bytes.phpt index fb40acc0c..df059c3c2 100644 --- a/tests/filters/bytes.phpt +++ b/tests/filters/bytes.phpt @@ -12,10 +12,20 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -Assert::same('0 B', Filters::bytes(0.1)); +test('no locale', function () { + $filters = new Filters; + Assert::same('0 B', $filters->bytes(0.1)); + Assert::same('-1.03 GB', $filters->bytes(-1024 * 1024 * 1050)); + Assert::same('8881.78 PB', $filters->bytes(1e19)); +}); -Assert::same('-1.03 GB', Filters::bytes(-1024 * 1024 * 1050)); +test('with locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; -Assert::same('8881.78 PB', Filters::bytes(1e19)); + Assert::same('0 B', $filters->bytes(0.1)); + Assert::same('-1,03 GB', $filters->bytes(-1024 * 1024 * 1050)); + Assert::same('8 881,78 PB', $filters->bytes(1e19)); +}); diff --git a/tests/filters/date.phpt b/tests/filters/date.phpt index b72abf48d..9000a029d 100644 --- a/tests/filters/date.phpt +++ b/tests/filters/date.phpt @@ -12,32 +12,47 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -setlocale(LC_TIME, 'C'); - - -Assert::null(Filters::date(null)); - - -Assert::same("23.\u{a0}1.\u{a0}1978", Filters::date(254_400_000)); - - -Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date('1978-05-05')); - - -Assert::same("5.\u{a0}5.\u{a0}1978", Filters::date(new DateTime('1978-05-05'))); - - -Assert::same('1978-01-23', Filters::date(254_400_000, 'Y-m-d')); - - -Assert::same('1212-09-26', Filters::date('1212-09-26', 'Y-m-d')); - - -Assert::same('1212-09-26', Filters::date(new DateTimeImmutable('1212-09-26'), 'Y-m-d')); - - -Assert::same('30:10:10', Filters::date(new DateInterval('PT30H10M10S'), '%H:%I:%S')); - - -date_default_timezone_set('America/Los_Angeles'); -Assert::same('07:09', Filters::date(1_408_284_571, 'H:i')); +test('no locale', function () { + $filters = new Filters; + + Assert::null($filters->date(null)); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05'))); + Assert::same('1978-01-23', $filters->date(254_400_000, 'Y-m-d')); + Assert::same('1212-09-26', $filters->date('1212-09-26', 'Y-m-d')); + Assert::same('1212-09-26', $filters->date(new DateTimeImmutable('1212-09-26'), 'Y-m-d')); + + // timestamp + date_default_timezone_set('America/Los_Angeles'); + Assert::same("23.\u{a0}1.\u{a0}1978", $filters->date(254_400_000)); + Assert::same('07:09', $filters->date(1_408_284_571, 'H:i')); +}); + + +test('date interval', function () { + $filters = new Filters; + + Assert::same('30:10:10', $filters->date(new DateInterval('PT30H10M10S'), '%H:%I:%S')); +}); + + +test('local date/time', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + // date format + Assert::null($filters->date(null, 'medium')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date('1978-05-05', 'medium')); + Assert::same('05.05.78', $filters->date(new DateTime('1978-05-05'), 'short')); + Assert::same("5.\u{a0}5.\u{a0}1978", $filters->date(new DateTime('1978-05-05'), 'medium')); + Assert::same("5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'long')); + Assert::same("pátek 5.\u{a0}května 1978", $filters->date(new DateTime('1978-05-05'), 'full')); + + // time format + Assert::same('12:13', $filters->date(new DateTime('12:13:14'), 'time')); + Assert::same('12:13:14', $filters->date(new DateTime('12:13:14'), 'time+sec')); + + // combined + Assert::same('05.05.78 12:13', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time')); + Assert::same('05.05.78 12:13:14', $filters->date(new DateTime('1978-05-05 12:13:14'), 'short+time+sec')); +}); diff --git a/tests/filters/number.phpt b/tests/filters/number.phpt new file mode 100644 index 000000000..eaebe281b --- /dev/null +++ b/tests/filters/number.phpt @@ -0,0 +1,55 @@ +number(0)); + Assert::same('0.00', $filters->number(0, 2)); + Assert::same('1,234', $filters->number(1234)); + Assert::same('123.46', $filters->number(123.456, 2)); + Assert::same('123.457', $filters->number(123.4567, 3)); + Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' ')); + Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.')); + Assert::same('-1,234', $filters->number(-1234)); + Assert::same('-1,234.57', $filters->number(-1234.5678, 2)); + Assert::same('nan', $filters->number(NAN, 2)); +}); + + +test('with locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + Assert::same('0', $filters->number(0)); + Assert::same('0,00', $filters->number(0, 2)); + Assert::same('1 234', $filters->number(1234)); + Assert::same('123,46', $filters->number(123.456, 2)); + Assert::same('123,457', $filters->number(123.4567, 3)); + Assert::same('-1 234', $filters->number(-1234)); + Assert::same('-1 234,57', $filters->number(-1234.5678, 2)); + Assert::same('NaN', $filters->number(NAN, 2)); + + // pattern + Assert::same('00 123,4560', $filters->number(123.456, '00,000.0000')); +}); + + +test('disabled locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + + Assert::same('1 234.56', $filters->number(1234.56, 2, '.', ' ')); + Assert::same('1.234,56', $filters->number(1234.56, 2, ',', '.')); +}); diff --git a/tests/filters/sort.phpt b/tests/filters/sort.phpt index 7d957aa1a..c60ee9260 100644 --- a/tests/filters/sort.phpt +++ b/tests/filters/sort.phpt @@ -31,13 +31,14 @@ function exportIterator(Traversable $iterator): array test('array', function () { - Assert::same([1 => 11, 0 => 22, 33], Filters::sort([22, 11, 33])); - Assert::same([], Filters::sort([])); + $filters = new Filters; + Assert::same([1 => 11, 0 => 22, 33], $filters->sort([22, 11, 33])); + Assert::same([], $filters->sort([])); }); test('iterator', function () { - $sorted = Filters::sort(iterator()); + $sorted = (new Filters)->sort(iterator()); Assert::same(3, count($sorted)); Assert::equal( @@ -52,7 +53,7 @@ test('iterator', function () { test('re-iteration', function () { - $sorted = Filters::sort(iterator()); + $sorted = (new Filters)->sort(iterator()); $res = [ [['a' => 55], ['k' => 22]], [['a' => 77], ['k' => 33]], @@ -72,7 +73,7 @@ test('re-iteration', function () { test('user comparison + array', function () { Assert::same( [2 => 33, 0 => 22, 1 => 11], - Filters::sort([22, 11, 33], fn($a, $b) => $b <=> $a) + (new Filters)->sort([22, 11, 33], fn($a, $b) => $b <=> $a) ); }); @@ -84,17 +85,18 @@ test('user comparison + iterator', function () { [['a' => 77], ['k' => 33]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), fn($a, $b) => $b <=> $a)), + exportIterator((new Filters)->sort(iterator(), fn($a, $b) => $b <=> $a)), ); }); test('array + by', function () { + $filters = new Filters; Assert::equal( [1 => (object) ['k' => 11], 0 => ['k' => 22], ['k' => 33]], - Filters::sort([['k' => 22], (object) ['k' => 11], ['k' => 33]], by: 'k'), + $filters->sort([['k' => 22], (object) ['k' => 11], ['k' => 33]], by: 'k'), ); - Assert::same([], Filters::sort([], by: 'k')); + Assert::same([], $filters->sort([], by: 'k')); }); @@ -105,7 +107,7 @@ test('iterator + by', function () { [['a' => 55], ['k' => 22]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), by: 'k')), + exportIterator((new Filters)->sort(iterator(), by: 'k')), ); }); @@ -113,7 +115,7 @@ test('iterator + by', function () { test('callback + array + by', function () { Assert::same( [1 => 11, 0 => 22, 33], - Filters::sort([22, 11, 33], by: fn($a) => $a * 11) + (new Filters)->sort([22, 11, 33], by: fn($a) => $a * 11) ); }); @@ -125,14 +127,15 @@ test('callback + iterator + by', function () { [['a' => 55], ['k' => 22]], [['a' => 66], (object) ['k' => 11]], ], - exportIterator(Filters::sort(iterator(), by: fn($a) => -((array) $a)['k'])), + exportIterator((new Filters)->sort(iterator(), by: fn($a) => -((array) $a)['k'])), ); }); test('array + byKey', function () { - Assert::same([1 => 11, 0 => 22, 33], Filters::sort([22, 11, 33])); - Assert::same([], Filters::sort([], byKey: true)); + $filters = new Filters; + Assert::same([1 => 11, 0 => 22, 33], $filters->sort([22, 11, 33])); + Assert::same([], $filters->sort([], byKey: true)); }); @@ -143,7 +146,7 @@ test('iterator + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), byKey: true)), + exportIterator((new Filters)->sort(iterator(), byKey: true)), ); }); @@ -151,7 +154,7 @@ test('iterator + byKey', function () { test('user comparison + array + byKey', function () { Assert::same( [2 => 33, 1 => 11, 0 => 22], - Filters::sort([22, 11, 33], fn($a, $b) => $b <=> $a, byKey: true), + (new Filters)->sort([22, 11, 33], fn($a, $b) => $b <=> $a, byKey: true), ); }); @@ -163,7 +166,7 @@ test('user comparison + iterator + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), fn($a, $b) => $b <=> $a, byKey: true)), + exportIterator((new Filters)->sort(iterator(), fn($a, $b) => $b <=> $a, byKey: true)), ); }); @@ -175,7 +178,7 @@ test('iterator + by + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 77], ['k' => 33]], ], - exportIterator(Filters::sort(iterator(), byKey: 'a')), + exportIterator((new Filters)->sort(iterator(), byKey: 'a')), ); }); @@ -187,6 +190,13 @@ test('callback + iterator + by + byKey', function () { [['a' => 66], (object) ['k' => 11]], [['a' => 55], ['k' => 22]], ], - exportIterator(Filters::sort(iterator(), byKey: fn($a) => -((array) $a)['a'])), + exportIterator((new Filters)->sort(iterator(), byKey: fn($a) => -((array) $a)['a'])), ); }); + + +test('locale', function () { + $filters = new Filters; + $filters->locale = 'cs_CZ'; + Assert::same([22, 2 => 'a', 1 => 'c', 4 => 'd', 3 => 'ch'], $filters->sort([22, 'c', 'a', 'ch', 'd'])); +}); From b80c3a628cfbfa35574f477007150afd181e55bf Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 18 Jun 2024 23:25:41 +0200 Subject: [PATCH 6/9] support for PHP 8.4 --- .github/workflows/tests.yml | 2 +- composer.json | 10 +++++----- src/Latte/Compiler/TagLexer.php | 2 +- src/Latte/Compiler/TemplateParser.php | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e1c9a739b..1105bac5b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.0', '8.1', '8.2', '8.3'] + php: ['8.0', '8.1', '8.2', '8.3', '8.4'] fail-fast: false diff --git a/composer.json b/composer.json index a735d680a..40946a0dd 100644 --- a/composer.json +++ b/composer.json @@ -15,16 +15,16 @@ } ], "require": { - "php": "8.0 - 8.3", + "php": "8.0 - 8.4", "ext-json": "*", "ext-tokenizer": "*" }, "require-dev": { - "nette/tester": "^2.0", - "tracy/tracy": "^2.3", - "nette/utils": "^3.0", + "nette/tester": "^2.5", + "tracy/tracy": "^2.10", + "nette/utils": "^4.0", "phpstan/phpstan": "^1", - "nette/php-generator": "^3.6 || ^4.0" + "nette/php-generator": "^4.0" }, "suggest": { "ext-iconv": "to use filters |reverse, |substring", diff --git a/src/Latte/Compiler/TagLexer.php b/src/Latte/Compiler/TagLexer.php index 38bbf014e..710b603e2 100644 --- a/src/Latte/Compiler/TagLexer.php +++ b/src/Latte/Compiler/TagLexer.php @@ -66,7 +66,7 @@ public function tokenize(string $input, ?Position $position = null): array /** @return Token[] */ - public function tokenizePartially(string $input, Position &$position, int $ofs = null): array + public function tokenizePartially(string $input, Position &$position, ?int $ofs = null): array { $this->input = $input; $this->offset = $ofs ?? $position->offset; diff --git a/src/Latte/Compiler/TemplateParser.php b/src/Latte/Compiler/TemplateParser.php index 2dd8eb9f6..279ee6406 100644 --- a/src/Latte/Compiler/TemplateParser.php +++ b/src/Latte/Compiler/TemplateParser.php @@ -82,7 +82,7 @@ public function parse(string $template): Nodes\TemplateNode } - public function parseFragment(callable $resolver, callable $after = null): FragmentNode + public function parseFragment(callable $resolver, ?callable $after = null): FragmentNode { $res = new FragmentNode; $save = [$this->lastResolver, $this->tag]; From 5b1d834f37f36e7cefacb19cbf9c5ea0d16e9e1e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 9 Jul 2024 12:57:55 +0200 Subject: [PATCH 7/9] unquoted strings can contain '+' --- src/Latte/Compiler/TagLexer.php | 2 +- tests/phpPrint/unquotedStrings.phpt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Latte/Compiler/TagLexer.php b/src/Latte/Compiler/TagLexer.php index 710b603e2..5749f602c 100644 --- a/src/Latte/Compiler/TagLexer.php +++ b/src/Latte/Compiler/TagLexer.php @@ -120,7 +120,7 @@ private function tokenizeCode(): void (? \\ (?&label) ( \\ (?&label) )* )| (? (?&label) ( \\ (?&label) )+ )| (? (?&label) (?= [ \t\r\n]* [(&=] ) )| - (? (?&label)((--?|\.)[a-zA-Z0-9_\x80-\xff]+)* )| + (? (?&label)((--?|\.|\+)[a-zA-Z0-9_\x80-\xff]+)* )| ( ( (? -> )| diff --git a/tests/phpPrint/unquotedStrings.phpt b/tests/phpPrint/unquotedStrings.phpt index 9f36af4d8..dd06168b2 100644 --- a/tests/phpPrint/unquotedStrings.phpt +++ b/tests/phpPrint/unquotedStrings.phpt @@ -18,8 +18,8 @@ $test = <<<'XX' a-b-c, a--b--c, - /* dots */ - a.b, + /* special chars */ + a.b+c, a . b, /* usage */ @@ -43,7 +43,7 @@ __halt_compiler(); MD5, 'a-b-c', 'a--b--c', -'a.b', +'a.b+c', 'a' . 'b', 'a-b.c-d', a.b(), From 7218197c219d1842d4e100211f96851ebc25efad Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 9 Jul 2024 12:50:36 +0200 Subject: [PATCH 8/9] Released version 3.0.17 --- src/Latte/Engine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Latte/Engine.php b/src/Latte/Engine.php index 00f33d4f1..a2e0d7c77 100644 --- a/src/Latte/Engine.php +++ b/src/Latte/Engine.php @@ -17,8 +17,8 @@ */ class Engine { - public const Version = '3.0.16'; - public const VersionId = 30016; + public const Version = '3.0.17'; + public const VersionId = 30017; /** @deprecated use Engine::Version */ public const From f227216dddad8f2feb885537b0170f34c76c8cca Mon Sep 17 00:00:00 2001 From: machina86 Date: Tue, 22 Oct 2024 09:03:14 -0700 Subject: [PATCH 9/9] Fix Filters File --- src/Latte/Essential/Filters.php | 54 ++------------------------------- 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/src/Latte/Essential/Filters.php b/src/Latte/Essential/Filters.php index 1974bf590..ace901d4c 100644 --- a/src/Latte/Essential/Filters.php +++ b/src/Latte/Essential/Filters.php @@ -169,7 +169,7 @@ public static function repeat(FilterInfo $info, $s, int $count): string /** * Date/time formatting. */ - public function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string + public static function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?string $format = null): ?string { $format ??= Latte\Runtime\Filters::$dateFormat; if ($time == null) { // intentionally == @@ -186,23 +186,8 @@ public function date(string|int|\DateTimeInterface|\DateInterval|null $time, ?st if (PHP_VERSION_ID >= 80100) { trigger_error("Function strftime() used by filter |date is deprecated since PHP 8.1, use format without % characters like 'Y-m-d'.", E_USER_DEPRECATED); } - return @strftime($format, $time->format('U') + 0); - } elseif (preg_match('#^(\+(short|medium|long|full))?(\+time(\+sec)?)?$#', '+' . $format, $m)) { - $formatter = new \IntlDateFormatter( - $this->getLocale('date'), - match ($m[2]) { - 'short' => \IntlDateFormatter::SHORT, - 'medium' => \IntlDateFormatter::MEDIUM, - 'long' => \IntlDateFormatter::LONG, - 'full' => \IntlDateFormatter::FULL, - '' => \IntlDateFormatter::NONE, - }, - isset($m[3]) ? (isset($m[4]) ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT) : \IntlDateFormatter::NONE, - ); - $res = $formatter->format($time); - $res = preg_replace('~(\d\.) ~', "\$1\u{a0}", $res); - return $res; + return @strftime($format, $time->format('U') + 0); } return $time->format($format); @@ -765,39 +750,4 @@ public static function random(string|array $values): mixed ? $values[array_rand($values, 1)] : null; } - - - /** - * Formats a number with grouped thousands and optionally decimal digits according to locale. - */ - public function number( - float $number, - string|int $patternOrDecimals = 0, - string $decimalSeparator = '.', - string $thousandsSeparator = ',', - ): string - { - if (is_int($patternOrDecimals) && $patternOrDecimals < 0) { - throw new Latte\RuntimeException("Filter |$name: number of decimal must not be negative"); - } elseif ($this->locale === null || func_num_args() > 2) { - return number_format($number, $patternOrDecimals, $decimalSeparator, $thousandsSeparator); - } - - $formatter = new \NumberFormatter($this->locale, \NumberFormatter::DECIMAL); - if (is_string($patternOrDecimals)) { - $formatter->setPattern($patternOrDecimals); - } else { - $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $patternOrDecimals); - } - return $formatter->format($number); - } - - - private function getLocale(string $name): string - { - if ($this->locale === null) { - throw new Latte\RuntimeException("Filter |$name requires the locale to be set using Engine::setLocale()"); - } - return $this->locale; - } }