This commit is contained in:
Javier Casares 2026-08-08 08:53:08 +00:00
commit 7d3fc1969d
52 changed files with 1776 additions and 295 deletions

View file

@ -30,7 +30,7 @@
"nette/schema": "<1.2.2"
},
"suggest": {
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-mbstring": "to use Strings::lower() etc...",

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function array_slice, array_splice, count, is_int;
use function array_splice, array_unshift, count, is_int;
/**
@ -123,8 +123,8 @@ class ArrayList implements \ArrayAccess, \Countable, \IteratorAggregate
*/
public function prepend(mixed $value): void
{
$first = array_slice($this->list, 0, 1);
$this->offsetSet(0, $value);
array_splice($this->list, 1, 0, $first);
// route the value through offsetSet() first so a validation added in a subclass isn't bypassed
$this->offsetSet(null, $value);
array_unshift($this->list, ...array_splice($this->list, -1));
}
}

View file

@ -304,7 +304,7 @@ class Arrays
{
$parts = is_array($path)
? $path
: preg_split('#(\[\]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
: preg_split('#(\[]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if (!$parts || $parts === ['->'] || $parts[0] === '=' || $parts[0] === '|') {
throw new Nette\InvalidArgumentException("Invalid path '" . (is_array($path) ? implode('', $path) : $path) . "'.");

View file

@ -0,0 +1,187 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use function array_merge, checkdate, implode, is_int, is_string, preg_match, preg_replace_callback, sprintf, trim;
/**
* Extends PHP's DateTimeImmutable with strict validation and additional factory methods.
* Invalid dates and times are rejected with an exception instead of being silently adjusted.
* All modifications return a new instance, the original object never changes.
*/
class DateTimeImmutable extends \DateTimeImmutable implements \JsonSerializable
{
/** matches relative sub-day parts (minutes, seconds, ...) that must be applied in UTC to be DST-safe */
private const RelativePattern = '/[+-]?\s*\d+\s+((microsecond|millisecond|[mµu]sec)s?|[mµ]s|sec(ond)?s?|min(ute)?s?|hours?)(\s+ago)?\b/iu';
/**
* Creates a DateTimeImmutable object from a string, UNIX timestamp, or other DateTimeInterface object.
* @throws \Exception if the date and time are not valid.
*/
public static function from(string|int|\DateTimeInterface|null $time): static
{
if ($time instanceof \DateTimeInterface) {
return static::createFromInterface($time);
} elseif (is_int($time)) {
return (new static)->setTimestamp($time);
} else { // textual or null
return new static((string) $time);
}
}
/**
* Creates DateTimeImmutable object.
* @throws \Exception if the date and time are not valid.
*/
public static function fromParts(
int $year,
int $month,
int $day,
int $hour = 0,
int $minute = 0,
float $second = 0.0,
): static
{
$sec = (int) floor($second);
return (new static)
->setDate($year, $month, $day)
->setTime($hour, $minute, $sec, (int) round(($second - $sec) * 1e6));
}
/**
* Returns a new DateTimeImmutable object formatted according to the specified format.
*/
public static function createFromFormat(
string $format,
string $datetime,
string|\DateTimeZone|null $timezone = null,
): static|false
{
if (is_string($timezone)) {
$timezone = new \DateTimeZone($timezone);
}
$date = parent::createFromFormat($format, $datetime, $timezone);
return $date ? static::from($date) : false;
}
public function __construct(string $datetime = 'now', ?\DateTimeZone $timezone = null)
{
if (preg_match(self::RelativePattern, $datetime)) {
// sub-day relative parts must be applied in UTC, which cannot happen inside a constructor => resolve & re-parse
$result = self::resolve(null, $datetime, $timezone);
parent::__construct($result->format('Y-m-d H:i:s.u'), $result->getTimezone());
} else {
parent::__construct($datetime, $timezone);
self::handleErrors($datetime);
}
}
public function modify(string $modifier): static
{
return static::createFromInterface(self::resolve($this, $modifier, null));
}
public function setDate(int $year, int $month, int $day): static
{
if (!checkdate($month, $day, $year)) {
throw new \Exception(sprintf('The date %04d-%02d-%02d is not valid.', $year, $month, $day));
}
return parent::setDate($year, $month, $day);
}
public function setTime(int $hour, int $minute, int $second = 0, int $microsecond = 0): static
{
if (
$hour < 0 || $hour > 23
|| $minute < 0 || $minute > 59
|| $second < 0 || $second >= 60
|| $microsecond < 0 || $microsecond >= 1_000_000
) {
throw new \Exception(sprintf('The time %02d:%02d:%08.5F is not valid.', $hour, $minute, $second + $microsecond / 1_000_000));
}
return parent::setTime($hour, $minute, $second, $microsecond);
}
/**
* Splits the input into absolute and relative parts and returns the resulting instant. Relative sub-day
* parts are applied in UTC so that crossing a DST boundary does not shift the result.
*/
private static function resolve(?\DateTimeInterface $base, string $input, ?\DateTimeZone $timezone): \DateTimeImmutable
{
$relPart = '';
$absPart = preg_replace_callback(
self::RelativePattern,
function ($m) use (&$relPart) {
$relPart .= $m[0] . ' ';
return '';
},
$input,
);
if ($base === null) {
$result = new \DateTimeImmutable($absPart, $timezone);
self::handleErrors($input);
} else {
$result = \DateTimeImmutable::createFromInterface($base);
if (trim($absPart) !== '') {
$modified = @$result->modify($absPart); // @ - on PHP 8.2 an invalid modifier emits a warning and returns false instead of throwing; handleErrors() turns it into an exception
self::handleErrors($input);
$result = $modified ?: $result;
}
}
if ($relPart !== '') {
$timezone ??= $result->getTimezone();
$result = $result
->setTimezone(new \DateTimeZone('UTC'))
->modify($relPart)
->setTimezone($timezone);
self::handleErrors($input);
}
return $result;
}
/**
* Returns JSON representation in ISO 8601 (used by JavaScript).
*/
public function jsonSerialize(): string
{
return $this->format('c');
}
/**
* Returns the date and time in the format 'Y-m-d H:i:s'.
*/
public function __toString(): string
{
return $this->format('Y-m-d H:i:s');
}
private static function handleErrors(string $value): void
{
$errors = self::getLastErrors();
$errors = array_merge($errors['errors'] ?? [], $errors['warnings'] ?? []);
if ($errors) {
throw new \Exception(implode(', ', $errors) . " '$value'");
}
}
}

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function array_pop, chmod, decoct, dirname, end, fclose, file_exists, file_get_contents, file_put_contents, fopen, implode, is_dir, is_file, is_link, mkdir, preg_match, preg_split, realpath, rename, rmdir, rtrim, sprintf, str_replace, stream_copy_to_stream, stream_is_local, strtr;
use function array_pop, chmod, decoct, dirname, end, fclose, file_exists, file_get_contents, file_put_contents, fopen, implode, is_dir, is_file, is_link, mkdir, preg_match, preg_split, realpath, rename, rmdir, rtrim, sprintf, str_replace, stream_copy_to_stream, stream_is_local, strtr, uniqid, unlink, usleep;
use const DIRECTORY_SEPARATOR;
@ -233,6 +233,36 @@ final class FileSystem
}
/**
* Writes the string to a file atomically: the content is written to a temporary file, which then replaces
* the target, so a concurrent reader never sees the file partially written or truncated.
* Creates the parent directory if it does not exist. Pass null as $mode to skip chmod.
* @throws Nette\IOException on error occurred
*/
public static function writeAtomic(string $file, string $content, ?int $mode = 0o666): void
{
$file = realpath($file) ?: $file; // writes through a symlink to its target, as write() does
$tmp = $file . '.' . uniqid('', more_entropy: true) . '.tmp';
try {
static::write($tmp, $content, $mode);
// plain rename() is atomic; static::rename() must not be used here, it deletes the target first
for ($i = 0; !@rename($tmp, $file); $i++) { // @ is escalated to exception
if (!Helpers::IsWindows || $i >= 20) {
throw new Nette\IOException(sprintf(
"Unable to write file '%s'. %s",
self::normalizePath($file),
Helpers::getLastError(),
));
}
usleep(5_000); // on Windows, rename fails while the target is open in another process
}
} catch (\Throwable $e) {
@unlink($tmp);
throw $e;
}
}
/**
* Sets file permissions to `$fileMode` or directory permissions to `$dirMode`.
* Recursively traverses and sets permissions on the entire contents of the directory as well.

View file

@ -8,8 +8,8 @@
namespace Nette\Utils;
use Nette;
use function array_merge, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_ends_with, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, usort;
use const GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
use function array_filter, array_merge, array_values, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, trigger_error, usort;
use const DIRECTORY_SEPARATOR, E_USER_DEPRECATED, GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
/**
@ -47,18 +47,19 @@ class Finder implements \IteratorAggregate
/**
* Begins search for files and directories matching mask.
* Begins search for files and directories matching mask. The ** wildcard searches recursively; a trailing slash limits the mask to directories.
* @param string|list<string> $masks
*/
public static function find(string|array $masks = ['*']): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
return (new static)->addMask($masks, 'dir')->addMask($masks, 'file');
$files = array_filter($masks, fn(string $mask): bool => !self::hasTrailingSeparator($mask)); // trailing slash means directories only
return (new static)->addMask($masks, 'dir')->addMask(array_values($files), 'file');
}
/**
* Begins search for files matching mask.
* Begins search for files matching mask. The ** wildcard searches recursively.
* @param string|list<string> $masks
*/
public static function findFiles(string|array $masks = ['*']): static
@ -69,7 +70,7 @@ class Finder implements \IteratorAggregate
/**
* Begins search for directories matching mask.
* Begins search for directories matching mask. The ** wildcard searches recursively.
* @param string|list<string> $masks
*/
public static function findDirectories(string|array $masks = ['*']): static
@ -103,24 +104,34 @@ class Finder implements \IteratorAggregate
private function addMask(array $masks, string $mode): static
{
foreach ($masks as $mask) {
$mask = FileSystem::unixSlashes($mask);
$orig = $mask;
if ($mode === 'dir') {
$mask = rtrim($mask, '/');
$mask = rtrim($mask, '/\\');
}
if ($mask === '' || ($mode === 'file' && str_ends_with($mask, '/'))) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
if ($mask === '' || ($mode === 'file' && self::hasTrailingSeparator($mask))) {
throw new Nette\InvalidArgumentException("Invalid mask '$orig'");
}
if (str_starts_with($mask, '**/')) {
$mask = substr($mask, 3);
}
$this->find[] = [$mask, $mode];
$this->find[] = [self::expandGlobStar($mask), $mode];
}
return $this;
}
private static function hasTrailingSeparator(string $mask): bool
{
return ($last = substr($mask, -1)) === '/' || $last === '\\';
}
// Expands a ** that is not followed by a slash into **/*, so that e.g. "test/**" and "**.c" search recursively.
private static function expandGlobStar(string $mask): string
{
return preg_replace('~(?<=^|[/\\\])\*\*(?![/\\\])~', '**/*', $mask);
}
/**
* Searches in the given directories. Wildcards are allowed.
* Searches in the given directories. Wildcards * and ? are allowed; unlike in masks, [ and ] are taken literally.
* @param string|list<string> $paths
*/
public function in(string|array $paths): static
@ -132,13 +143,13 @@ class Finder implements \IteratorAggregate
/**
* Searches recursively from the given directories. Wildcards are allowed.
* Searches recursively from the given directories. Wildcards * and ? are allowed; unlike in masks, [ and ] are taken literally.
* @param string|list<string> $paths
*/
public function from(string|array $paths): static
{
$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
$this->addLocation($paths, '/**');
$this->addLocation($paths, DIRECTORY_SEPARATOR . '**');
return $this;
}
@ -150,7 +161,7 @@ class Finder implements \IteratorAggregate
if ($path === '') {
throw new Nette\InvalidArgumentException("Invalid directory '$path'");
}
$path = rtrim(FileSystem::unixSlashes($path), '/');
$path = rtrim($path, '/\\');
$this->in[] = $path . $ext;
}
}
@ -216,24 +227,29 @@ class Finder implements \IteratorAggregate
/**
* Skips entries that matches the given masks relative to the ones defined with the in() or from() methods.
* Skips entries that match the given masks, using the same grammar as find() masks, relative to the directories from in() or from().
* A trailing slash excludes directories only; a trailing /* or /** excludes the contents while keeping the directory itself.
* @param string|list<string> $masks
*/
public function exclude(string|array $masks): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
foreach ($masks as $mask) {
$orig = $mask;
$mask = FileSystem::unixSlashes($mask);
if (!preg_match('~^/?(\*\*/)?(.+)(/\*\*|/\*|/|)$~D', $mask, $m)) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
if (FileSystem::isAbsolute($mask) || $mask === '..' || str_starts_with($mask, '../')) {
trigger_error("Absolute or ../ mask '$orig' in exclude() is deprecated and will change meaning, use a mask relative to the searched directory.", E_USER_DEPRECATED);
}
if (!preg_match('~^/?(\*\*/)?(.+?)(/\*\*|/\*|/|)$~D', $mask, $m)) {
throw new Nette\InvalidArgumentException("Invalid mask '$orig'");
}
$end = $m[3];
$re = $this->buildPattern($m[2]);
$re = $this->buildPattern(self::expandGlobStar($m[2]));
$filter = fn(FileInfo $file): bool => ($end && !$file->isDir())
|| !preg_match($re, FileSystem::unixSlashes($file->getRelativePathname()));
$this->descentFilter($filter);
if ($end !== '/*') {
if ($end === '' || $end === '/') {
$this->filter($filter);
}
}
@ -340,7 +356,6 @@ class Finder implements \IteratorAggregate
if ($item instanceof self) {
yield from $item->getIterator();
} else {
$item = FileSystem::platformSlashes($item);
yield $item => new FileInfo($item);
}
}
@ -361,7 +376,7 @@ class Finder implements \IteratorAggregate
}
try {
$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);
$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME);
} catch (\UnexpectedValueException $e) {
if ($this->ignoreUnreadableDirs) {
return;
@ -370,7 +385,7 @@ class Finder implements \IteratorAggregate
}
}
$files = $this->convertToFiles($pathNames, implode('/', $subdirs), FileSystem::isAbsolute($dir));
$files = $this->convertToFiles($pathNames, implode(DIRECTORY_SEPARATOR, $subdirs), FileSystem::isAbsolute($dir));
if ($this->sort) {
$files = iterator_to_array($files);
@ -417,9 +432,8 @@ class Finder implements \IteratorAggregate
{
foreach ($pathNames as $pathName) {
if (!$absolute) {
$pathName = preg_replace('~\.?/~A', '', $pathName);
$pathName = preg_replace('~\.?[\\\/]~A', '', $pathName);
}
$pathName = FileSystem::platformSlashes($pathName);
yield new FileInfo($pathName, $relativePath);
}
}
@ -457,7 +471,7 @@ class Finder implements \IteratorAggregate
} else {
foreach ($this->in ?: ['.'] as $in) {
$in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()
$splits[] = self::splitRecursivePart($in . '/' . $mask);
$splits[] = self::splitRecursivePart($in . DIRECTORY_SEPARATOR . $mask);
}
}
@ -488,11 +502,13 @@ class Finder implements \IteratorAggregate
*/
private static function splitRecursivePart(string $path): array
{
$a = strrpos($path, '/');
$parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);
$pos = strrpos(strtr($path, '\\', '/'), '/');
$dir = $pos === false ? '' : substr($path, 0, $pos + 1);
$file = $pos === false ? $path : substr($path, $pos + 1);
$parts = preg_split('~(?<=^|[\\\/])\*\*($|[\\\/])~', $dir, 2);
return isset($parts[1])
? [$parts[0], $parts[1] . substr($path, $a + 1), true]
: [$parts[0], substr($path, $a + 1), false];
? [$parts[0], $parts[1] . $file, true]
: [$parts[0], $file, false];
}
@ -501,6 +517,7 @@ class Finder implements \IteratorAggregate
*/
private function buildPattern(string $mask): string
{
$mask = FileSystem::unixSlashes($mask);
if ($mask === '*') {
return '##';
} elseif (str_starts_with($mask, './')) {

View file

@ -8,8 +8,8 @@
namespace Nette\Utils;
use Nette\HtmlStringable;
use function array_merge, array_splice, count, explode, func_num_args, html_entity_decode, htmlspecialchars, http_build_query, implode, is_array, is_bool, is_float, is_object, is_string, json_encode, max, number_format, rtrim, str_contains, str_repeat, str_replace, strip_tags, strncmp, strpbrk, substr;
use const ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
use function array_merge, array_splice, count, explode, func_num_args, html_entity_decode, htmlspecialchars, http_build_query, implode, is_array, is_bool, is_float, is_object, is_string, json_encode, max, number_format, rtrim, str_contains, str_repeat, str_replace, strip_tags, strncmp, strpbrk, substr, trigger_error, ucfirst;
use const E_USER_DEPRECATED, ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
/**
@ -229,6 +229,9 @@ use const ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
* @method self width(?int $val)
* @method self wrap(?string $val)
*
* @method static static text(mixed $text)
* @method static static html(mixed $html)
*
* @implements \IteratorAggregate<int, self|string>
* @implements \ArrayAccess<int, self|string>
*/
@ -280,8 +283,19 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Creates a nameless element (fragment) containing the given children.
* Everything except HtmlStringable is escaped; use Html::html() for raw HTML. Nulls are skipped.
*/
public static function fragment(HtmlStringable|\Stringable|string|int|null ...$children): static
{
return (new static)->add(...$children);
}
/**
* Returns an object representing HTML text.
* @deprecated use Html::html()
*/
public static function fromHtml(string $html): static
{
@ -291,6 +305,7 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
/**
* Returns an object representing plain text.
* @deprecated use Html::text()
*/
public static function fromText(string $text): static
{
@ -473,6 +488,10 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
*/
final public function __call(string $m, array $args): mixed
{
if ($m === 'text' || $m === 'html') {
trigger_error("Method \$el->$m() is deprecated, use set" . ucfirst($m) . "() for content or setAttribute() for the '$m' attribute; Html::$m() is a static factory.", E_USER_DEPRECATED);
}
$p = substr($m, 0, 3);
if ($p === 'get' || $p === 'set' || $p === 'add') {
$m = substr($m, 3);
@ -498,6 +517,20 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Creates element with escaped text (Html::text()) or raw HTML (Html::html()) content.
* @param mixed[] $args
*/
final public static function __callStatic(string $name, array $args): static
{
return match ($name) {
'text' => (new static)->setText(...$args),
'html' => (new static)->setHtml(...$args),
default => ObjectHelpers::strictStaticCall(static::class, $name),
};
}
/**
* Special setter for element's attribute.
* @param array<string, mixed> $query
@ -575,6 +608,21 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
}
/**
* Appends the given children. Everything except HtmlStringable is escaped; use Html::html() for raw HTML. Nulls are skipped.
*/
public function add(HtmlStringable|\Stringable|string|int|null ...$children): static
{
foreach ($children as $child) {
if ($child !== null) {
$this->addText($child);
}
}
return $this;
}
/**
* Adds new element's child.
*/
@ -813,8 +861,8 @@ class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringab
$q = str_contains($value, '"') ? "'" : '"';
$s .= ' ' . $key . '=' . $q
. str_replace(
['&', $q, '<'],
['&amp;', $q === '"' ? '&quot;' : '&#39;', '<'],
['&', $q],
['&amp;', $q === '"' ? '&quot;' : '&#39;'],
$value,
)
. (str_contains($value, '`') && strpbrk($value, ' <>"\'') === false ? ' ' : '')

View file

@ -327,7 +327,7 @@ class Image
ImageType::PNG => IMG_PNG,
ImageType::GIF => IMG_GIF,
ImageType::WEBP => IMG_WEBP,
ImageType::AVIF => 256, // IMG_AVIF,
ImageType::AVIF => IMG_AVIF,
ImageType::BMP => IMG_BMP,
default => 0,
});
@ -347,7 +347,7 @@ class Image
$flag & IMG_JPG ? ImageType::JPEG : null,
$flag & IMG_PNG ? ImageType::PNG : null,
$flag & IMG_WEBP ? ImageType::WEBP : null,
$flag & 256 ? ImageType::AVIF : null, // IMG_AVIF
$flag & IMG_AVIF ? ImageType::AVIF : null,
$flag & IMG_BMP ? ImageType::BMP : null,
]);
}

View file

@ -195,7 +195,7 @@ final class Iterables
return new class ($factory(...)) implements \IteratorAggregate {
public function __construct(
/** @var \Closure(): iterable<mixed, mixed> */
private \Closure $factory,
private readonly \Closure $factory,
) {
}

View file

@ -8,7 +8,7 @@
namespace Nette\Utils;
use Nette;
use function defined, is_int, json_decode, json_encode, json_last_error, json_last_error_msg;
use function is_int, json_decode, json_encode, json_last_error, json_last_error_msg;
use const JSON_BIGINT_AS_STRING, JSON_FORCE_OBJECT, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT, JSON_HEX_TAG, JSON_OBJECT_AS_ARRAY, JSON_PRESERVE_ZERO_FRACTION, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE;
@ -51,8 +51,7 @@ final class Json
| ($htmlSafe ? JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG : 0);
}
$flags |= JSON_UNESCAPED_SLASHES
| (defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0); // since PHP 5.6.6 & PECL JSON-C 1.3.7
$flags |= JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION;
$json = json_encode($value, $flags);
if ($error = json_last_error()) {

View file

@ -220,7 +220,7 @@ final class ObjectHelpers
if ($rp->isPublic() && !$rp->isStatic()) {
$prop = $name >= 'onA' && $name < 'on_' ? 'event' : true;
}
} catch (\ReflectionException $e) {
} catch (\ReflectionException) {
}
}

View file

@ -8,6 +8,8 @@
namespace Nette\Utils;
use Nette;
use function is_resource, is_string, strlen;
use const PHP_VERSION_ID;
/**
@ -41,7 +43,11 @@ final class Process
/** @var array<int, true> Output IDs whose target resource was supplied by the caller and must not be closed here. */
private array $callerOutputs = [];
/** @var array<int, true> Output IDs whose pipe is backed by a temporary file (Windows < 8.5 workaround). */
private array $fileBackedOutputs = [];
private float $startTime;
private bool $detached = false;
/**
@ -113,7 +119,7 @@ final class Process
mixed $stdin,
mixed $stdout,
mixed $stderr,
private ?float $timeout,
private readonly ?float $timeout,
) {
$descriptors = [
self::StdIn => $this->createInputDescriptor($stdin),
@ -145,11 +151,32 @@ final class Process
public function __destruct()
{
if ($this->detached) {
return;
}
$this->outputBuffers = [];
$this->terminate();
}
/**
* Detaches the process: it keeps running in the background and is not terminated when the object
* is destroyed. STDIN and output pipes are closed, so the output must not be captured in memory
* (pass a file name, a resource or false as $stdout/$stderr). Only the destructor behavior changes:
* wait() and getExitCode() still block ($timeout still applies) and terminate() still kills it.
* On POSIX, a detached child that exits early stays a zombie until the script ends.
*/
public function detach(): void
{
if ($this->outputBuffers !== []) {
throw new Nette\InvalidStateException('Cannot detach process: its output is captured in memory, pass a file name, a resource or false as $stdout/$stderr.');
}
$this->detached = true;
$this->closeStdInput();
$this->closeOutputPipes();
}
/**
* Checks if the process is currently running.
*/
@ -189,12 +216,12 @@ final class Process
/**
* Reads any new data from the captured pipes into the buffers, so a process producing more output
* than the OS pipe buffer holds does not block. (On Windows the captured output is a file and never blocks.)
* than the OS pipe buffer holds does not block. With $final (process already finished) reads to EOF.
*/
private function drainPipes(): void
private function drainPipes(bool $final = false): void
{
foreach ([self::StdOut, self::StdErr] as $id) {
$this->readFromPipe($id);
$this->readFromPipe($id, $final);
}
}
@ -397,16 +424,39 @@ final class Process
* Reads any new data from the specified pipe and appends it to the buffer. Does nothing if the output
* is not captured or the pipe is already closed (or handed over to another process).
*/
private function readFromPipe(int $id): void
private function readFromPipe(int $id, bool $final = false): void
{
if (!isset($this->outputBuffers[$id]) || !is_resource($this->outputPipes[$id] ?? null)) {
$pipe = $this->outputPipes[$id] ?? null;
if (!isset($this->outputBuffers[$id]) || !is_resource($pipe)) {
return;
} elseif (Helpers::IsWindows) {
fseek($this->outputPipes[$id], strlen($this->outputBuffers[$id]));
} elseif (isset($this->fileBackedOutputs[$id])) {
// Windows < 8.5: captured output is a temporary file; read whatever was appended since last time
fseek($pipe, strlen($this->outputBuffers[$id]));
$this->outputBuffers[$id] .= stream_get_contents($pipe);
} elseif ($final) {
// the process has finished, everything is buffered in the pipe; non-blocking read on POSIX
// (cannot hang on a leaked descendant still holding the write end), blocking read to EOF on
// Windows, where non-blocking mode does not work and stream_select() may miss buffered data
stream_set_blocking($pipe, Helpers::IsWindows);
$this->outputBuffers[$id] .= stream_get_contents($pipe);
} else {
stream_set_blocking($this->outputPipes[$id], false);
// non-blocking drain: stream_set_blocking(false) works on POSIX only, so reads are also
// guarded by stream_select(), which works on Windows pipes since PHP 8.5 (PeekNamedPipe fix)
stream_set_blocking($pipe, false);
$read = [$pipe];
$write = $except = [];
while (@stream_select($read, $write, $except, 0, 0) > 0) {
$chunk = fread($pipe, 8192);
if ($chunk === false || $chunk === '') {
break;
}
$this->outputBuffers[$id] .= $chunk;
$read = [$pipe];
}
}
$this->outputBuffers[$id] .= stream_get_contents($this->outputPipes[$id]);
}
@ -470,9 +520,12 @@ final class Process
} elseif ($output === null) {
$this->outputBuffers[$id] = '';
$this->outputBufferOffsets[$id] = 0;
// On Windows anonymous pipes are blocking and cannot be polled without freezing the process,
// so captured output is backed by a temporary file that can be read non-blockingly (needed for timeouts).
return Helpers::IsWindows ? tmpfile() : ['pipe', 'w'];
if (Helpers::IsWindows && PHP_VERSION_ID < 80500) {
// Windows < 8.5: stream_select() doesn't work on pipes, capture into a temp file that reads non-blockingly
$this->fileBackedOutputs[$id] = true;
return tmpfile();
}
return ['pipe', 'w'];
} else {
throw new Nette\InvalidArgumentException('Output must be string, resource, bool or null, ' . get_debug_type($output) . ' given.');
@ -485,7 +538,7 @@ final class Process
*/
private function close(): void
{
$this->drainPipes();
$this->drainPipes(final: true);
$this->closeStdInput();
$this->closeOutputPipes();
proc_close($this->process);
@ -494,7 +547,7 @@ final class Process
/**
* Closes the output pipes that this class opened; resources supplied by the caller are left untouched.
* (The temporary file backing captured output on Windows is removed by fclose() itself.)
* (The temporary file backing captured output on Windows < 8.5 is removed by fclose() itself.)
*/
private function closeOutputPipes(): void
{

View file

@ -230,7 +230,7 @@ final class Reflection
try {
$tokens = \PhpToken::tokenize($code, TOKEN_PARSE);
} catch (\ParseError $e) {
trigger_error($e->getMessage(), E_USER_NOTICE);
trigger_error($e->getMessage());
$tokens = [];
}

View file

@ -9,7 +9,7 @@ namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function array_keys, array_map, array_shift, array_values, bin2hex, class_exists, defined, extension_loaded, function_exists, htmlspecialchars, htmlspecialchars_decode, iconv, iconv_strlen, iconv_substr, implode, in_array, is_array, is_callable, is_int, is_object, is_string, key, max, mb_convert_case, mb_strlen, mb_strtolower, mb_strtoupper, mb_substr, pack, preg_last_error, preg_last_error_msg, preg_quote, preg_replace, str_contains, str_ends_with, str_repeat, str_replace, str_starts_with, strlen, strpos, strrev, strrpos, strtolower, strtoupper, strtr, substr, trim, unpack, utf8_decode;
use function array_keys, array_map, array_shift, array_values, bin2hex, class_exists, defined, extension_loaded, function_exists, htmlspecialchars, htmlspecialchars_decode, iconv, iconv_strlen, iconv_substr, implode, in_array, is_array, is_callable, is_int, is_object, is_string, key, max, mb_convert_case, mb_strlen, mb_strtolower, mb_strtoupper, mb_substr, pack, preg_last_error, preg_last_error_msg, preg_quote, preg_replace, str_contains, str_ends_with, str_repeat, str_replace, str_starts_with, strlen, strpos, strrev, strrpos, strtolower, strtoupper, strtr, substr, trim, unpack;
use const ENT_IGNORE, ENT_NOQUOTES, ICONV_IMPL, MB_CASE_TITLE, PHP_EOL, PREG_OFFSET_CAPTURE, PREG_PATTERN_ORDER, PREG_SET_ORDER, PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_NO_EMPTY, PREG_SPLIT_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL;
@ -333,8 +333,8 @@ class Strings
public static function compare(string $left, string $right, ?int $length = null): bool
{
if (class_exists('Normalizer', autoload: false)) {
$left = \Normalizer::normalize($left, \Normalizer::FORM_D); // form NFD is faster
$right = \Normalizer::normalize($right, \Normalizer::FORM_D); // form NFD is faster
$left = \Normalizer::normalize($left, \Normalizer::FORM_D) ?: $left; // form NFD is faster, false on invalid UTF-8
$right = \Normalizer::normalize($right, \Normalizer::FORM_D) ?: $right; // form NFD is faster, false on invalid UTF-8
}
if ($length < 0) {
@ -385,7 +385,7 @@ class Strings
return match (true) {
extension_loaded('mbstring') => (int) mb_strlen($s, 'UTF-8'),
extension_loaded('iconv') => (int) iconv_strlen($s, 'UTF-8'),
default => strlen(@utf8_decode($s)), // deprecated
default => strlen((string) preg_replace('#[\x80-\xBF]#', '', $s)), // strips UTF-8 continuation bytes
};
}
@ -396,7 +396,7 @@ class Strings
public static function trim(string $s, string $charlist = self::TrimCharacters): string
{
$charlist = preg_quote($charlist, '#');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du', '');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du');
}

View file

@ -337,14 +337,15 @@ class Validators
public static function isUrl(string $value): bool
{
$alpha = "a-z\x80-\xFF";
$octet = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'; // 0..255
return (bool) preg_match(<<<XX
(^(?n)
https?://(
(([-_0-9$alpha]+\\.)* # subdomain
[0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)? # domain
[$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain
|\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3} # IPv4
|\\[[0-9a-f:]{3,39}\\] # IPv6
|$octet(\\.$octet){3} # IPv4
|\\[[0-9a-f:]{3,39}] # IPv6
)(:\\d{1,5})? # port
(/\\S*)? # path
(\\?\\S*)? # query
@ -359,7 +360,7 @@ class Validators
*/
public static function isUri(string $value): bool
{
return (bool) preg_match('#^[a-z\d+\.-]+:\S+$#Di', $value);
return (bool) preg_match('#^[a-z\d+.-]+:\S+$#Di', $value);
}