glastree_on_gitea

This commit is contained in:
2026-05-26 08:14:29 +02:00
commit 0bed099d05
9556 changed files with 1186307 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit74199c0a3889e8d1256a25f93805ef56::getLoader();
Vendored Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nesbot/carbon/bin/carbon)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nesbot/carbon/bin/carbon');
}
}
return include __DIR__ . '/..'.'/nesbot/carbon/bin/carbon';
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/error-handler/Resources/bin/patch-type-declarations)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations');
}
}
return include __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations';
Vendored Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
}
}
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';
Vendored Executable
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../phpunit/phpunit/phpunit)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'] = $GLOBALS['__PHPUNIT_ISOLATION_BLACKLIST'] = array(realpath(__DIR__ . '/..'.'/phpunit/phpunit/phpunit'));
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = 'phpvfscomposer://'.$this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data);
$data = str_replace('__FILE__', var_export($this->realpath, true), $data);
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/phpunit/phpunit/phpunit');
}
}
return include __DIR__ . '/..'.'/phpunit/phpunit/phpunit';
Vendored Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../laravel/pint/builds/pint)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/laravel/pint/builds/pint');
}
}
return include __DIR__ . '/..'.'/laravel/pint/builds/pint';
Vendored Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../psy/psysh/bin/psysh)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/psy/psysh/bin/psysh');
}
}
return include __DIR__ . '/..'.'/psy/psysh/bin/psysh';
Vendored Executable
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/var-dumper/Resources/bin/var-dump-server)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server');
}
}
return include __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server';
+634
View File
@@ -0,0 +1,634 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.14.8](https://github.com/brick/math/releases/tag/0.14.8) - 2026-02-10
🗑️ **Deprecations**
- Method `BigInteger::testBit()` is deprecated, use `isBitSet()` instead
**New features**
- New method: `BigInteger::isBitSet()` (replaces `testBit()`)
- New method: `BigNumber::toString()` (alias of magic method `__toString()`)
👌 **Improvements**
- Performance optimization of `BigRational` comparison methods
- More exceptions have been documented with `@throws` annotations
## [0.14.7](https://github.com/brick/math/releases/tag/0.14.7) - 2026-02-07
**New features**
- `clamp()` is now available on the base `BigNumber` class
👌 **Improvements**
- Improved `@throws` exception documentation
## [0.14.6](https://github.com/brick/math/releases/tag/0.14.6) - 2026-02-05
🗑️ **Deprecations**
- Not passing a `$scale` to `BigDecimal::dividedBy()` is deprecated; **`$scale` will be required in 0.15**
👌 **Improvements**
- `BigRational::toFloat()` never returns `NAN` anymore
## [0.14.5](https://github.com/brick/math/releases/tag/0.14.5) - 2026-02-03
🗑️ **Deprecations**
- Not passing a rounding mode to `BigInteger::sqrt()` and `BigDecimal::sqrt()` triggers a deprecation notice: **the default rounding mode will change from `Down` to `Unnecessary` in 0.15**
**New features**
- `BigInteger::sqrt()` and `BigDecimal::sqrt()` now support rounding
- `abs()` and `negated()` methods are now available on the base `BigNumber` class
👌 **Improvements**
- Alphabet is now checked for duplicate characters in `BigInteger::(from|to)ArbitraryBase()`
- `BigNumber::ofNullable()` is now marked as `@pure`
## [0.14.4](https://github.com/brick/math/releases/tag/0.14.4) - 2026-02-02
🗑️ **Deprecations**
- Passing a negative modulus to `BigInteger::mod()` is deprecated to align with Euclidean modulo semantics; it will throw `NegativeNumberException` in 0.15
- Method `BigDecimal::stripTrailingZeros()` is deprecated, use `strippedOfTrailingZeros()` instead
**New features**
- `BigInteger::modPow()` now accepts negative bases
- New method: `BigDecimal::strippedOfTrailingZeros()` (replaces `stripTrailingZeros()`)
👌 **Improvements**
- `clamp()` methods are now marked as `@pure`
## [0.14.3](https://github.com/brick/math/releases/tag/0.14.3) - 2026-02-01
**New features**
- New method: `BigInteger::lcm()`
- New method: `BigInteger::lcmAll()`
- New method: `BigRational::toRepeatingDecimalString()`
🐛 **Bug fixes**
- `BigInteger::gcdAll()` / `gcdMultiple()` could return a negative result when used with a single negative number
## [0.14.2](https://github.com/brick/math/releases/tag/0.14.2) - 2026-01-30
🗑️ **Deprecations**
- **Passing `float` values to `of()` or arithmetic methods is deprecated** and will be removed in 0.15; cast to string explicitly to preserve the previous behaviour (#105)
- **Accessing `RoundingMode` enum cases through upper snake case (e.g. `HALF_UP`) is deprecated**, use the pascal case version (e.g. `HalfUp`) instead
- Method `BigInteger::gcdMultiple()` is deprecated, use `gcdAll()` instead
- Method `BigDecimal::exactlyDividedBy()` is deprecated, use `dividedByExact()` instead
- Method `BigDecimal::getIntegralPart()` is deprecated (will be removed in 0.15, and re-introduced as returning `BigInteger` in 0.16)
- Method `BigDecimal::getFractionalPart()` is deprecated (will be removed in 0.15, and re-introduced as returning `BigDecimal` with a different meaning in 0.16)
- Method `BigRational::nd()` is deprecated, use `ofFraction()` instead
- Method `BigRational::quotient()` is deprecated, use `getIntegralPart()` instead
- Method `BigRational::remainder()` is deprecated, use `$number->getNumerator()->remainder($number->getDenominator())` instead
- Method `BigRational::quotientAndRemainder()` is deprecated, use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead
**New features**
- New method: `BigInteger::gcdAll()` (replaces `gcdMultiple()`)
- New method: `BigRational::clamp()`
- New method: `BigRational::ofFraction()` (replaces `nd()`)
- New method: `BigRational::getIntegralPart()` (replaces `quotient()`)
- New method: `BigRational::getFractionalPart()`
👌 **Improvements**
- `BigInteger::modInverse()` now accepts `BigNumber|int|float|string` instead of just `BigInteger`
- `BigInteger::gcdMultiple()` now accepts `BigNumber|int|float|string` instead of just `BigInteger`
🐛 **Bug fixes**
- `BigInteger::clamp()` and `BigDecimal::clamp()` now throw an exception on inverted bounds, instead of returning an incorrect result
## [0.14.1](https://github.com/brick/math/releases/tag/0.14.1) - 2025-11-24
**New features**
- New method: `BigNumber::ofNullable()` (#94 by @mrkh995)
**Compatibility fixes**
- Fixed warnings on PHP 8.5 (#101 and #102 by @julien-boudry)
## [0.14.0](https://github.com/brick/math/releases/tag/0.14.0) - 2025-08-29
**New features**
- New methods: `BigInteger::clamp()` and `BigDecimal::clamp()` (#96 by @JesterIruka)
**Improvements**
- All pure methods in `BigNumber` classes are now marked as `@pure` for better static analysis
💥 **Breaking changes**
- Minimum PHP version is now 8.2
- `BigNumber` classes are now `readonly`
- `BigNumber` is now marked as sealed: it must not be extended outside of this package
- Exception classes are now `final`
## [0.13.1](https://github.com/brick/math/releases/tag/0.13.1) - 2025-03-29
**Improvements**
- `__toString()` methods of `BigInteger` and `BigDecimal` are now type-hinted as returning `numeric-string` instead of `string` (#90 by @vudaltsov)
## [0.13.0](https://github.com/brick/math/releases/tag/0.13.0) - 2025-03-03
💥 **Breaking changes**
- `BigDecimal::ofUnscaledValue()` no longer throws an exception if the scale is negative
- `MathException` now extends `RuntimeException` instead of `Exception`; this reverts the change introduced in version `0.11.0` (#82)
**New features**
- `BigDecimal::ofUnscaledValue()` allows a negative scale (and converts the values to create a zero scale number)
## [0.12.3](https://github.com/brick/math/releases/tag/0.12.3) - 2025-02-28
**New features**
- `BigDecimal::getPrecision()` Returns the number of significant digits in a decimal number
## [0.12.2](https://github.com/brick/math/releases/tag/0.12.2) - 2025-02-26
⚡️ **Performance improvements**
- Division in `NativeCalculator` is now faster for small divisors, thanks to [@Izumi-kun](https://github.com/Izumi-kun) in [#87](https://github.com/brick/math/pull/87).
👌 **Improvements**
- Add missing `RoundingNecessaryException` to the `@throws` annotation of `BigNumber::of()`
## [0.12.1](https://github.com/brick/math/releases/tag/0.12.1) - 2023-11-29
⚡️ **Performance improvements**
- `BigNumber::of()` is now faster, thanks to [@SebastienDug](https://github.com/SebastienDug) in [#77](https://github.com/brick/math/pull/77).
## [0.12.0](https://github.com/brick/math/releases/tag/0.12.0) - 2023-11-26
💥 **Breaking changes**
- Minimum PHP version is now 8.1
- `RoundingMode` is now an `enum`; if you're type-hinting rounding modes, you need to type-hint against `RoundingMode` instead of `int` now
- `BigNumber` classes do not implement the `Serializable` interface anymore (they use the [new custom object serialization mechanism](https://wiki.php.net/rfc/custom_object_serialization))
- The following breaking changes only affect you if you're creating your own `BigNumber` subclasses:
- the return type of `BigNumber::of()` is now `static`
- `BigNumber` has a new abstract method `from()`
- all `public` and `protected` functions of `BigNumber` are now `final`
## [0.11.0](https://github.com/brick/math/releases/tag/0.11.0) - 2023-01-16
💥 **Breaking changes**
- Minimum PHP version is now 8.0
- Methods accepting a union of types are now strongly typed<sup>*</sup>
- `MathException` now extends `Exception` instead of `RuntimeException`
<sup>* You may now run into type errors if you were passing `Stringable` objects to `of()` or any of the methods
internally calling `of()`, with `strict_types` enabled. You can fix this by casting `Stringable` objects to `string`
first.</sup>
## [0.10.2](https://github.com/brick/math/releases/tag/0.10.2) - 2022-08-11
👌 **Improvements**
- `BigRational::toFloat()` now simplifies the fraction before performing division (#73) thanks to @olsavmic
## [0.10.1](https://github.com/brick/math/releases/tag/0.10.1) - 2022-08-02
**New features**
- `BigInteger::gcdMultiple()` returns the GCD of multiple `BigInteger` numbers
## [0.10.0](https://github.com/brick/math/releases/tag/0.10.0) - 2022-06-18
💥 **Breaking changes**
- Minimum PHP version is now 7.4
## [0.9.3](https://github.com/brick/math/releases/tag/0.9.3) - 2021-08-15
🚀 **Compatibility with PHP 8.1**
- Support for custom object serialization; this removes a warning on PHP 8.1 due to the `Serializable` interface being deprecated (#60) thanks @TRowbotham
## [0.9.2](https://github.com/brick/math/releases/tag/0.9.2) - 2021-01-20
🐛 **Bug fix**
- Incorrect results could be returned when using the BCMath calculator, with a default scale set with `bcscale()`, on PHP >= 7.2 (#55).
## [0.9.1](https://github.com/brick/math/releases/tag/0.9.1) - 2020-08-19
**New features**
- `BigInteger::not()` returns the bitwise `NOT` value
🐛 **Bug fixes**
- `BigInteger::toBytes()` could return an incorrect binary representation for some numbers
- The bitwise operations `and()`, `or()`, `xor()` on `BigInteger` could return an incorrect result when the GMP extension is not available
## [0.9.0](https://github.com/brick/math/releases/tag/0.9.0) - 2020-08-18
👌 **Improvements**
- `BigNumber::of()` now accepts `.123` and `123.` formats, both of which return a `BigDecimal`
💥 **Breaking changes**
- Deprecated method `BigInteger::powerMod()` has been removed - use `modPow()` instead
- Deprecated method `BigInteger::parse()` has been removed - use `fromBase()` instead
## [0.8.17](https://github.com/brick/math/releases/tag/0.8.17) - 2020-08-19
🐛 **Bug fix**
- `BigInteger::toBytes()` could return an incorrect binary representation for some numbers
- The bitwise operations `and()`, `or()`, `xor()` on `BigInteger` could return an incorrect result when the GMP extension is not available
## [0.8.16](https://github.com/brick/math/releases/tag/0.8.16) - 2020-08-18
🚑 **Critical fix**
- This version reintroduces the deprecated `BigInteger::parse()` method, that has been removed by mistake in version `0.8.9` and should have lasted for the whole `0.8` release cycle.
**New features**
- `BigInteger::modInverse()` calculates a modular multiplicative inverse
- `BigInteger::fromBytes()` creates a `BigInteger` from a byte string
- `BigInteger::toBytes()` converts a `BigInteger` to a byte string
- `BigInteger::randomBits()` creates a pseudo-random `BigInteger` of a given bit length
- `BigInteger::randomRange()` creates a pseudo-random `BigInteger` between two bounds
💩 **Deprecations**
- `BigInteger::powerMod()` is now deprecated in favour of `modPow()`
## [0.8.15](https://github.com/brick/math/releases/tag/0.8.15) - 2020-04-15
🐛 **Fixes**
- added missing `ext-json` requirement, due to `BigNumber` implementing `JsonSerializable`
⚡️ **Optimizations**
- additional optimization in `BigInteger::remainder()`
## [0.8.14](https://github.com/brick/math/releases/tag/0.8.14) - 2020-02-18
**New features**
- `BigInteger::getLowestSetBit()` returns the index of the rightmost one bit
## [0.8.13](https://github.com/brick/math/releases/tag/0.8.13) - 2020-02-16
**New features**
- `BigInteger::isEven()` tests whether the number is even
- `BigInteger::isOdd()` tests whether the number is odd
- `BigInteger::testBit()` tests if a bit is set
- `BigInteger::getBitLength()` returns the number of bits in the minimal representation of the number
## [0.8.12](https://github.com/brick/math/releases/tag/0.8.12) - 2020-02-03
🛠️ **Maintenance release**
Classes are now annotated for better static analysis with [psalm](https://psalm.dev/).
This is a maintenance release: no bug fixes, no new features, no breaking changes.
## [0.8.11](https://github.com/brick/math/releases/tag/0.8.11) - 2020-01-23
**New feature**
`BigInteger::powerMod()` performs a power-with-modulo operation. Useful for crypto.
## [0.8.10](https://github.com/brick/math/releases/tag/0.8.10) - 2020-01-21
**New feature**
`BigInteger::mod()` returns the **modulo** of two numbers. The *modulo* differs from the *remainder* when the signs of the operands are different.
## [0.8.9](https://github.com/brick/math/releases/tag/0.8.9) - 2020-01-08
⚡️ **Performance improvements**
A few additional optimizations in `BigInteger` and `BigDecimal` when one of the operands can be returned as is. Thanks to @tomtomsen in #24.
## [0.8.8](https://github.com/brick/math/releases/tag/0.8.8) - 2019-04-25
🐛 **Bug fixes**
- `BigInteger::toBase()` could return an empty string for zero values (BCMath & Native calculators only, GMP calculator unaffected)
**New features**
- `BigInteger::toArbitraryBase()` converts a number to an arbitrary base, using a custom alphabet
- `BigInteger::fromArbitraryBase()` converts a string in an arbitrary base, using a custom alphabet, back to a number
These methods can be used as the foundation to convert strings between different bases/alphabets, using BigInteger as an intermediate representation.
💩 **Deprecations**
- `BigInteger::parse()` is now deprecated in favour of `fromBase()`
`BigInteger::fromBase()` works the same way as `parse()`, with 2 minor differences:
- the `$base` parameter is required, it does not default to `10`
- it throws a `NumberFormatException` instead of an `InvalidArgumentException` when the number is malformed
## [0.8.7](https://github.com/brick/math/releases/tag/0.8.7) - 2019-04-20
**Improvements**
- Safer conversion from `float` when using custom locales
- **Much faster** `NativeCalculator` implementation 🚀
You can expect **at least a 3x performance improvement** for common arithmetic operations when using the library on systems without GMP or BCMath; it gets exponentially faster on multiplications with a high number of digits. This is due to calculations now being performed on whole blocks of digits (the block size depending on the platform, 32-bit or 64-bit) instead of digit-by-digit as before.
## [0.8.6](https://github.com/brick/math/releases/tag/0.8.6) - 2019-04-11
**New method**
`BigNumber::sum()` returns the sum of one or more numbers.
## [0.8.5](https://github.com/brick/math/releases/tag/0.8.5) - 2019-02-12
**Bug fix**: `of()` factory methods could fail when passing a `float` in environments using a `LC_NUMERIC` locale with a decimal separator other than `'.'` (#20).
Thanks @manowark 👍
## [0.8.4](https://github.com/brick/math/releases/tag/0.8.4) - 2018-12-07
**New method**
`BigDecimal::sqrt()` calculates the square root of a decimal number, to a given scale.
## [0.8.3](https://github.com/brick/math/releases/tag/0.8.3) - 2018-12-06
**New method**
`BigInteger::sqrt()` calculates the square root of a number (thanks @peter279k).
**New exception**
`NegativeNumberException` is thrown when calling `sqrt()` on a negative number.
## [0.8.2](https://github.com/brick/math/releases/tag/0.8.2) - 2018-11-08
**Performance update**
- Further improvement of `toInt()` performance
- `NativeCalculator` can now perform some multiplications more efficiently
## [0.8.1](https://github.com/brick/math/releases/tag/0.8.1) - 2018-11-07
Performance optimization of `toInt()` methods.
## [0.8.0](https://github.com/brick/math/releases/tag/0.8.0) - 2018-10-13
**Breaking changes**
The following deprecated methods have been removed. Use the new method name instead:
| Method removed | Replacement method |
| --- | --- |
| `BigDecimal::getIntegral()` | `BigDecimal::getIntegralPart()` |
| `BigDecimal::getFraction()` | `BigDecimal::getFractionalPart()` |
---
**New features**
`BigInteger` has been augmented with 5 new methods for bitwise operations:
| New method | Description |
| --- | --- |
| `and()` | performs a bitwise `AND` operation on two numbers |
| `or()` | performs a bitwise `OR` operation on two numbers |
| `xor()` | performs a bitwise `XOR` operation on two numbers |
| `shiftedLeft()` | returns the number shifted left by a number of bits |
| `shiftedRight()` | returns the number shifted right by a number of bits |
Thanks to @DASPRiD 👍
## [0.7.3](https://github.com/brick/math/releases/tag/0.7.3) - 2018-08-20
**New method:** `BigDecimal::hasNonZeroFractionalPart()`
**Renamed/deprecated methods:**
- `BigDecimal::getIntegral()` has been renamed to `getIntegralPart()` and is now deprecated
- `BigDecimal::getFraction()` has been renamed to `getFractionalPart()` and is now deprecated
## [0.7.2](https://github.com/brick/math/releases/tag/0.7.2) - 2018-07-21
**Performance update**
`BigInteger::parse()` and `toBase()` now use GMP's built-in base conversion features when available.
## [0.7.1](https://github.com/brick/math/releases/tag/0.7.1) - 2018-03-01
This is a maintenance release, no code has been changed.
- When installed with `--no-dev`, the autoloader does not autoload tests anymore
- Tests and other files unnecessary for production are excluded from the dist package
This will help make installations more compact.
## [0.7.0](https://github.com/brick/math/releases/tag/0.7.0) - 2017-10-02
Methods renamed:
- `BigNumber:sign()` has been renamed to `getSign()`
- `BigDecimal::unscaledValue()` has been renamed to `getUnscaledValue()`
- `BigDecimal::scale()` has been renamed to `getScale()`
- `BigDecimal::integral()` has been renamed to `getIntegral()`
- `BigDecimal::fraction()` has been renamed to `getFraction()`
- `BigRational::numerator()` has been renamed to `getNumerator()`
- `BigRational::denominator()` has been renamed to `getDenominator()`
Classes renamed:
- `ArithmeticException` has been renamed to `MathException`
## [0.6.2](https://github.com/brick/math/releases/tag/0.6.2) - 2017-10-02
The base class for all exceptions is now `MathException`.
`ArithmeticException` has been deprecated, and will be removed in 0.7.0.
## [0.6.1](https://github.com/brick/math/releases/tag/0.6.1) - 2017-10-02
A number of methods have been renamed:
- `BigNumber:sign()` is deprecated; use `getSign()` instead
- `BigDecimal::unscaledValue()` is deprecated; use `getUnscaledValue()` instead
- `BigDecimal::scale()` is deprecated; use `getScale()` instead
- `BigDecimal::integral()` is deprecated; use `getIntegral()` instead
- `BigDecimal::fraction()` is deprecated; use `getFraction()` instead
- `BigRational::numerator()` is deprecated; use `getNumerator()` instead
- `BigRational::denominator()` is deprecated; use `getDenominator()` instead
The old methods will be removed in version 0.7.0.
## [0.6.0](https://github.com/brick/math/releases/tag/0.6.0) - 2017-08-25
- Minimum PHP version is now [7.1](https://gophp71.org/); for PHP 5.6 and PHP 7.0 support, use version `0.5`
- Deprecated method `BigDecimal::withScale()` has been removed; use `toScale()` instead
- Method `BigNumber::toInteger()` has been renamed to `toInt()`
## [0.5.4](https://github.com/brick/math/releases/tag/0.5.4) - 2016-10-17
`BigNumber` classes now implement [JsonSerializable](http://php.net/manual/en/class.jsonserializable.php).
The JSON output is always a string.
## [0.5.3](https://github.com/brick/math/releases/tag/0.5.3) - 2016-03-31
This is a bugfix release. Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.5.2](https://github.com/brick/math/releases/tag/0.5.2) - 2015-08-06
The `$scale` parameter of `BigDecimal::dividedBy()` is now optional again.
## [0.5.1](https://github.com/brick/math/releases/tag/0.5.1) - 2015-07-05
**New method: `BigNumber::toScale()`**
This allows to convert any `BigNumber` to a `BigDecimal` with a given scale, using rounding if necessary.
## [0.5.0](https://github.com/brick/math/releases/tag/0.5.0) - 2015-07-04
**New features**
- Common `BigNumber` interface for all classes, with the following methods:
- `sign()` and derived methods (`isZero()`, `isPositive()`, ...)
- `compareTo()` and derived methods (`isEqualTo()`, `isGreaterThan()`, ...) that work across different `BigNumber` types
- `toBigInteger()`, `toBigDecimal()`, `toBigRational`() conversion methods
- `toInteger()` and `toFloat()` conversion methods to native types
- Unified `of()` behaviour: every class now accepts any type of number, provided that it can be safely converted to the current type
- New method: `BigDecimal::exactlyDividedBy()`; this method automatically computes the scale of the result, provided that the division yields a finite number of digits
- New methods: `BigRational::quotient()` and `remainder()`
- Fine-grained exceptions: `DivisionByZeroException`, `RoundingNecessaryException`, `NumberFormatException`
- Factory methods `zero()`, `one()` and `ten()` available in all classes
- Rounding mode reintroduced in `BigInteger::dividedBy()`
This release also comes with many performance improvements.
---
**Breaking changes**
- `BigInteger`:
- `getSign()` is renamed to `sign()`
- `toString()` is renamed to `toBase()`
- `BigInteger::dividedBy()` now throws an exception by default if the remainder is not zero; use `quotient()` to get the previous behaviour
- `BigDecimal`:
- `getSign()` is renamed to `sign()`
- `getUnscaledValue()` is renamed to `unscaledValue()`
- `getScale()` is renamed to `scale()`
- `getIntegral()` is renamed to `integral()`
- `getFraction()` is renamed to `fraction()`
- `divideAndRemainder()` is renamed to `quotientAndRemainder()`
- `dividedBy()` now takes a **mandatory** `$scale` parameter **before** the rounding mode
- `toBigInteger()` does not accept a `$roundingMode` parameter anymore
- `toBigRational()` does not simplify the fraction anymore; explicitly add `->simplified()` to get the previous behaviour
- `BigRational`:
- `getSign()` is renamed to `sign()`
- `getNumerator()` is renamed to `numerator()`
- `getDenominator()` is renamed to `denominator()`
- `of()` is renamed to `nd()`, while `parse()` is renamed to `of()`
- Miscellaneous:
- `ArithmeticException` is moved to an `Exception\` sub-namespace
- `of()` factory methods now throw `NumberFormatException` instead of `InvalidArgumentException`
## [0.4.3](https://github.com/brick/math/releases/tag/0.4.3) - 2016-03-31
Backport of two bug fixes from the 0.5 branch:
- `BigInteger::parse()` did not always throw `InvalidArgumentException` as expected
- Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.4.2](https://github.com/brick/math/releases/tag/0.4.2) - 2015-06-16
New method: `BigDecimal::stripTrailingZeros()`
## [0.4.1](https://github.com/brick/math/releases/tag/0.4.1) - 2015-06-12
Introducing a `BigRational` class, to perform calculations on fractions of any size.
## [0.4.0](https://github.com/brick/math/releases/tag/0.4.0) - 2015-06-12
Rounding modes have been removed from `BigInteger`, and are now a concept specific to `BigDecimal`.
`BigInteger::dividedBy()` now always returns the quotient of the division.
## [0.3.5](https://github.com/brick/math/releases/tag/0.3.5) - 2016-03-31
Backport of two bug fixes from the 0.5 branch:
- `BigInteger::parse()` did not always throw `InvalidArgumentException` as expected
- Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.3.4](https://github.com/brick/math/releases/tag/0.3.4) - 2015-06-11
New methods:
- `BigInteger::remainder()` returns the remainder of a division only
- `BigInteger::gcd()` returns the greatest common divisor of two numbers
## [0.3.3](https://github.com/brick/math/releases/tag/0.3.3) - 2015-06-07
Fix `toString()` not handling negative numbers.
## [0.3.2](https://github.com/brick/math/releases/tag/0.3.2) - 2015-06-07
`BigInteger` and `BigDecimal` now have a `getSign()` method that returns:
- `-1` if the number is negative
- `0` if the number is zero
- `1` if the number is positive
## [0.3.1](https://github.com/brick/math/releases/tag/0.3.1) - 2015-06-05
Minor performance improvements
## [0.3.0](https://github.com/brick/math/releases/tag/0.3.0) - 2015-06-04
The `$roundingMode` and `$scale` parameters have been swapped in `BigDecimal::dividedBy()`.
## [0.2.2](https://github.com/brick/math/releases/tag/0.2.2) - 2015-06-04
Stronger immutability guarantee for `BigInteger` and `BigDecimal`.
So far, it would have been possible to break immutability of these classes by calling the `unserialize()` internal function. This release fixes that.
## [0.2.1](https://github.com/brick/math/releases/tag/0.2.1) - 2015-06-02
Added `BigDecimal::divideAndRemainder()`
## [0.2.0](https://github.com/brick/math/releases/tag/0.2.0) - 2015-05-22
- `min()` and `max()` do not accept an `array` anymore, but a variable number of parameters
- **minimum PHP version is now 5.6**
- continuous integration with PHP 7
## [0.1.1](https://github.com/brick/math/releases/tag/0.1.1) - 2014-09-01
- Added `BigInteger::power()`
- Added HHVM support
## [0.1.0](https://github.com/brick/math/releases/tag/0.1.0) - 2014-08-31
First beta release.
+20
View File
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013-present Benjamin Morel
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
{
"name": "brick/math",
"description": "Arbitrary-precision arithmetic library",
"type": "library",
"keywords": [
"Brick",
"Math",
"Mathematics",
"Arbitrary-precision",
"Arithmetic",
"BigInteger",
"BigDecimal",
"BigRational",
"BigNumber",
"Bignum",
"Decimal",
"Rational",
"Integer"
],
"license": "MIT",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"php-coveralls/php-coveralls": "^2.2",
"phpstan/phpstan": "2.1.22"
},
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Brick\\Math\\Tests\\": "tests/"
}
}
}
+975
View File
@@ -0,0 +1,975 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NegativeNumberException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\Internal\Calculator;
use Brick\Math\Internal\CalculatorRegistry;
use InvalidArgumentException;
use LogicException;
use Override;
use function func_num_args;
use function in_array;
use function intdiv;
use function max;
use function rtrim;
use function sprintf;
use function str_pad;
use function str_repeat;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
use const STR_PAD_LEFT;
/**
* An arbitrarily large decimal number.
*
* This class is immutable.
*
* The scale of the number is the number of digits after the decimal point. It is always positive or zero.
*/
final readonly class BigDecimal extends BigNumber
{
/**
* The unscaled value of this decimal number.
*
* This is a string of digits with an optional leading minus sign.
* No leading zero must be present.
* No leading minus sign must be present if the value is 0.
*/
private string $value;
/**
* The scale (number of digits after the decimal point) of this decimal number.
*
* This must be zero or more.
*/
private int $scale;
/**
* Protected constructor. Use a factory method to obtain an instance.
*
* @param string $value The unscaled value, validated.
* @param int $scale The scale, validated.
*
* @pure
*/
protected function __construct(string $value, int $scale = 0)
{
$this->value = $value;
$this->scale = $scale;
}
/**
* Creates a BigDecimal from an unscaled value and a scale.
*
* Example: `(12345, 3)` will result in the BigDecimal `12.345`.
*
* A negative scale is normalized to zero by appending zeros to the unscaled value.
*
* Example: `(12345, -3)` will result in the BigDecimal `12345000`.
*
* @param BigNumber|int|float|string $value The unscaled value. Must be convertible to a BigInteger.
* @param int $scale The scale of the number. If negative, the scale will be set to zero
* and the unscaled value will be adjusted accordingly.
*
* @throws MathException If the value is not valid, or is not convertible to a BigInteger.
*
* @pure
*/
public static function ofUnscaledValue(BigNumber|int|float|string $value, int $scale = 0): BigDecimal
{
$value = BigInteger::of($value)->toString();
if ($scale < 0) {
if ($value !== '0') {
$value .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($value, $scale);
}
/**
* Returns a BigDecimal representing zero, with a scale of zero.
*
* @pure
*/
public static function zero(): BigDecimal
{
/** @var BigDecimal|null $zero */
static $zero;
if ($zero === null) {
$zero = new BigDecimal('0');
}
return $zero;
}
/**
* Returns a BigDecimal representing one, with a scale of zero.
*
* @pure
*/
public static function one(): BigDecimal
{
/** @var BigDecimal|null $one */
static $one;
if ($one === null) {
$one = new BigDecimal('1');
}
return $one;
}
/**
* Returns a BigDecimal representing ten, with a scale of zero.
*
* @pure
*/
public static function ten(): BigDecimal
{
/** @var BigDecimal|null $ten */
static $ten;
if ($ten === null) {
$ten = new BigDecimal('10');
}
return $ten;
}
/**
* Returns the sum of this number and the given one.
*
* The result has a scale of `max($this->scale, $that->scale)`.
*
* @param BigNumber|int|float|string $that The number to add. Must be convertible to a BigDecimal.
*
* @throws MathException If the number is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function plus(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0' && $that->scale <= $this->scale) {
return $this;
}
if ($this->value === '0' && $this->scale <= $that->scale) {
return $that;
}
[$a, $b] = $this->scaleValues($this, $that);
$value = CalculatorRegistry::get()->add($a, $b);
$scale = max($this->scale, $that->scale);
return new BigDecimal($value, $scale);
}
/**
* Returns the difference of this number and the given one.
*
* The result has a scale of `max($this->scale, $that->scale)`.
*
* @param BigNumber|int|float|string $that The number to subtract. Must be convertible to a BigDecimal.
*
* @throws MathException If the number is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function minus(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0' && $that->scale <= $this->scale) {
return $this;
}
[$a, $b] = $this->scaleValues($this, $that);
$value = CalculatorRegistry::get()->sub($a, $b);
$scale = max($this->scale, $that->scale);
return new BigDecimal($value, $scale);
}
/**
* Returns the product of this number and the given one.
*
* The result has a scale of `$this->scale + $that->scale`.
*
* @param BigNumber|int|float|string $that The multiplier. Must be convertible to a BigDecimal.
*
* @throws MathException If the multiplier is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function multipliedBy(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '1' && $that->scale === 0) {
return $this;
}
if ($this->value === '1' && $this->scale === 0) {
return $that;
}
$value = CalculatorRegistry::get()->mul($this->value, $that->value);
$scale = $this->scale + $that->scale;
return new BigDecimal($value, $scale);
}
/**
* Returns the result of the division of this number by the given one, at the given scale.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
* @param int|null $scale The desired scale. Omitting this parameter is deprecated; it will be required in 0.15.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used and the result cannot be represented
* exactly at the given scale.
*
* @pure
*/
public function dividedBy(BigNumber|int|float|string $that, ?int $scale = null, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
if ($scale === null) {
// @phpstan-ignore-next-line
trigger_error(
'Not passing a $scale to BigDecimal::dividedBy() is deprecated. ' .
'Use $a->dividedBy($b, $a->getScale(), $roundingMode) to retain current behavior.',
E_USER_DEPRECATED,
);
$scale = $this->scale;
} elseif ($scale < 0) {
throw new InvalidArgumentException('Scale must not be negative.');
}
if ($that->value === '1' && $that->scale === 0 && $scale === $this->scale) {
return $this;
}
$p = $this->valueWithMinScale($that->scale + $scale);
$q = $that->valueWithMinScale($this->scale - $scale);
$result = CalculatorRegistry::get()->divRound($p, $q, $roundingMode);
return new BigDecimal($result, $scale);
}
/**
* Returns the exact result of the division of this number by the given one.
*
* The scale of the result is automatically calculated to fit all the fraction digits.
*
* @deprecated Will be removed in 0.15. Use dividedByExact() instead.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not a valid number, is not convertible to a BigDecimal, is zero,
* or the result yields an infinite number of digits.
*/
public function exactlyDividedBy(BigNumber|int|float|string $that): BigDecimal
{
trigger_error(
'BigDecimal::exactlyDividedBy() is deprecated and will be removed in 0.15. Use dividedByExact() instead.',
E_USER_DEPRECATED,
);
return $this->dividedByExact($that);
}
/**
* Returns the exact result of the division of this number by the given one.
*
* The scale of the result is automatically calculated to fit all the fraction digits.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
* @throws RoundingNecessaryException If the result yields an infinite number of digits.
*
* @pure
*/
public function dividedByExact(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0') {
throw DivisionByZeroException::divisionByZero();
}
[, $b] = $this->scaleValues($this, $that);
$d = rtrim($b, '0');
$scale = strlen($b) - strlen($d);
$calculator = CalculatorRegistry::get();
foreach ([5, 2] as $prime) {
for (; ;) {
$lastDigit = (int) $d[-1];
if ($lastDigit % $prime !== 0) {
break;
}
$d = $calculator->divQ($d, (string) $prime);
$scale++;
}
}
return $this->dividedBy($that, $scale)->strippedOfTrailingZeros();
}
/**
* Returns this number exponentiated to the given value.
*
* The result has a scale of `$this->scale * $exponent`.
*
* @throws InvalidArgumentException If the exponent is not in the range 0 to 1,000,000.
*
* @pure
*/
public function power(int $exponent): BigDecimal
{
if ($exponent === 0) {
return BigDecimal::one();
}
if ($exponent === 1) {
return $this;
}
if ($exponent < 0 || $exponent > Calculator::MAX_POWER) {
throw new InvalidArgumentException(sprintf(
'The exponent %d is not in the range 0 to %d.',
$exponent,
Calculator::MAX_POWER,
));
}
return new BigDecimal(CalculatorRegistry::get()->pow($this->value, $exponent), $this->scale * $exponent);
}
/**
* Returns the quotient of the division of this number by the given one.
*
* The quotient has a scale of `0`.
*
* Examples:
*
* - `7.5` quotient `3` returns `2`
* - `7.5` quotient `-3` returns `-2`
* - `-7.5` quotient `3` returns `-2`
* - `-7.5` quotient `-3` returns `2`
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function quotient(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
$quotient = CalculatorRegistry::get()->divQ($p, $q);
return new BigDecimal($quotient, 0);
}
/**
* Returns the remainder of the division of this number by the given one.
*
* The remainder has a scale of `max($this->scale, $that->scale)`.
* The remainder, when non-zero, has the same sign as the dividend.
*
* Examples:
*
* - `7.5` remainder `3` returns `1.5`
* - `7.5` remainder `-3` returns `1.5`
* - `-7.5` remainder `3` returns `-1.5`
* - `-7.5` remainder `-3` returns `-1.5`
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function remainder(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
$remainder = CalculatorRegistry::get()->divR($p, $q);
$scale = max($this->scale, $that->scale);
return new BigDecimal($remainder, $scale);
}
/**
* Returns the quotient and remainder of the division of this number by the given one.
*
* The quotient has a scale of `0`, and the remainder has a scale of `max($this->scale, $that->scale)`.
*
* Examples:
*
* - `7.5` quotientAndRemainder `3` returns [`2`, `1.5`]
* - `7.5` quotientAndRemainder `-3` returns [`-2`, `1.5`]
* - `-7.5` quotientAndRemainder `3` returns [`-2`, `-1.5`]
* - `-7.5` quotientAndRemainder `-3` returns [`2`, `-1.5`]
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @return array{BigDecimal, BigDecimal} An array containing the quotient and the remainder.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function quotientAndRemainder(BigNumber|int|float|string $that): array
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
[$quotient, $remainder] = CalculatorRegistry::get()->divQR($p, $q);
$scale = max($this->scale, $that->scale);
$quotient = new BigDecimal($quotient, 0);
$remainder = new BigDecimal($remainder, $scale);
return [$quotient, $remainder];
}
/**
* Returns the square root of this number, rounded to the given scale according to the given rounding mode.
*
* @param int $scale The target scale. Must be non-negative.
* @param RoundingMode $roundingMode The rounding mode to use, defaults to Down.
* ⚠️ WARNING: the default rounding mode was kept as Down for backward
* compatibility, but will change to Unnecessary in version 0.15. Pass a rounding
* mode explicitly to avoid this upcoming breaking change.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws NegativeNumberException If this number is negative.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used and the result cannot be represented
* exactly at the given scale.
*
* @pure
*/
public function sqrt(int $scale, RoundingMode $roundingMode = RoundingMode::Down): BigDecimal
{
if (func_num_args() === 1) {
// @phpstan-ignore-next-line
trigger_error(
'The default rounding mode of BigDecimal::sqrt() will change from Down to Unnecessary in version 0.15. ' .
'Pass a rounding mode explicitly to avoid this breaking change.',
E_USER_DEPRECATED,
);
}
if ($scale < 0) {
throw new InvalidArgumentException('Scale must not be negative.');
}
if ($this->value === '0') {
return new BigDecimal('0', $scale);
}
if ($this->value[0] === '-') {
throw new NegativeNumberException('Cannot calculate the square root of a negative number.');
}
$value = $this->value;
$inputScale = $this->scale;
if ($inputScale % 2 !== 0) {
$value .= '0';
$inputScale++;
}
$calculator = CalculatorRegistry::get();
// Keep one extra digit for rounding.
$intermediateScale = max($scale, intdiv($inputScale, 2)) + 1;
$value .= str_repeat('0', 2 * $intermediateScale - $inputScale);
$sqrt = $calculator->sqrt($value);
$isExact = $calculator->mul($sqrt, $sqrt) === $value;
if (! $isExact) {
if ($roundingMode === RoundingMode::Unnecessary) {
throw RoundingNecessaryException::roundingNecessary();
}
// Non-perfect-square sqrt is irrational, so the true value is strictly above this sqrt floor.
// Add one at the intermediate scale to guarantee Up/Ceiling round up at the target scale.
if (in_array($roundingMode, [RoundingMode::Up, RoundingMode::Ceiling], true)) {
$sqrt = $calculator->add($sqrt, '1');
}
// Irrational sqrt cannot land exactly on a midpoint; treat tie-to-down modes as HalfUp.
elseif (in_array($roundingMode, [RoundingMode::HalfDown, RoundingMode::HalfEven, RoundingMode::HalfFloor], true)) {
$roundingMode = RoundingMode::HalfUp;
}
}
return (new BigDecimal($sqrt, $intermediateScale))->toScale($scale, $roundingMode);
}
/**
* Returns a copy of this BigDecimal with the decimal point moved to the left by the given number of places.
*
* @pure
*/
public function withPointMovedLeft(int $n): BigDecimal
{
if ($n === 0) {
return $this;
}
if ($n < 0) {
return $this->withPointMovedRight(-$n);
}
return new BigDecimal($this->value, $this->scale + $n);
}
/**
* Returns a copy of this BigDecimal with the decimal point moved to the right by the given number of places.
*
* @pure
*/
public function withPointMovedRight(int $n): BigDecimal
{
if ($n === 0) {
return $this;
}
if ($n < 0) {
return $this->withPointMovedLeft(-$n);
}
$value = $this->value;
$scale = $this->scale - $n;
if ($scale < 0) {
if ($value !== '0') {
$value .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($value, $scale);
}
/**
* Returns a copy of this BigDecimal with any trailing zeros removed from the fractional part.
*
* @deprecated Use strippedOfTrailingZeros() instead.
*/
public function stripTrailingZeros(): BigDecimal
{
trigger_error(
'BigDecimal::stripTrailingZeros() is deprecated, use strippedOfTrailingZeros() instead.',
E_USER_DEPRECATED,
);
return $this->strippedOfTrailingZeros();
}
/**
* Returns a copy of this BigDecimal with any trailing zeros removed from the fractional part.
*
* @pure
*/
public function strippedOfTrailingZeros(): BigDecimal
{
if ($this->scale === 0) {
return $this;
}
$trimmedValue = rtrim($this->value, '0');
if ($trimmedValue === '') {
return BigDecimal::zero();
}
$trimmableZeros = strlen($this->value) - strlen($trimmedValue);
if ($trimmableZeros === 0) {
return $this;
}
if ($trimmableZeros > $this->scale) {
$trimmableZeros = $this->scale;
}
$value = substr($this->value, 0, -$trimmableZeros);
$scale = $this->scale - $trimmableZeros;
return new BigDecimal($value, $scale);
}
#[Override]
public function negated(): static
{
return new BigDecimal(CalculatorRegistry::get()->neg($this->value), $this->scale);
}
#[Override]
public function compareTo(BigNumber|int|float|string $that): int
{
$that = BigNumber::of($that);
if ($that instanceof BigInteger) {
$that = $that->toBigDecimal();
}
if ($that instanceof BigDecimal) {
[$a, $b] = $this->scaleValues($this, $that);
return CalculatorRegistry::get()->cmp($a, $b);
}
return -$that->compareTo($this);
}
#[Override]
public function getSign(): int
{
return ($this->value === '0') ? 0 : (($this->value[0] === '-') ? -1 : 1);
}
/**
* @pure
*/
public function getUnscaledValue(): BigInteger
{
return self::newBigInteger($this->value);
}
/**
* @pure
*/
public function getScale(): int
{
return $this->scale;
}
/**
* Returns the number of significant digits in the number.
*
* This is the number of digits to both sides of the decimal point, stripped of leading zeros.
* The sign has no impact on the result.
*
* Examples:
* 0 => 0
* 0.0 => 0
* 123 => 3
* 123.456 => 6
* 0.00123 => 3
* 0.0012300 => 5
*
* @pure
*/
public function getPrecision(): int
{
$value = $this->value;
if ($value === '0') {
return 0;
}
$length = strlen($value);
return ($value[0] === '-') ? $length - 1 : $length;
}
/**
* Returns a string representing the integral part of this decimal number.
*
* Example: `-123.456` => `-123`.
*
* @deprecated Will be removed in 0.15 and re-introduced as returning BigInteger in 0.16.
*/
public function getIntegralPart(): string
{
trigger_error(
'BigDecimal::getIntegralPart() is deprecated and will be removed in 0.15. It will be re-introduced as returning BigInteger in 0.16.',
E_USER_DEPRECATED,
);
if ($this->scale === 0) {
return $this->value;
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, 0, -$this->scale);
}
/**
* Returns a string representing the fractional part of this decimal number.
*
* If the scale is zero, an empty string is returned.
*
* Examples: `-123.456` => '456', `123` => ''.
*
* @deprecated Will be removed in 0.15 and re-introduced as returning BigDecimal with a different meaning in 0.16.
*/
public function getFractionalPart(): string
{
trigger_error(
'BigDecimal::getFractionalPart() is deprecated and will be removed in 0.15. It will be re-introduced as returning BigDecimal with a different meaning in 0.16.',
E_USER_DEPRECATED,
);
if ($this->scale === 0) {
return '';
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, -$this->scale);
}
/**
* Returns whether this decimal number has a non-zero fractional part.
*
* @pure
*/
public function hasNonZeroFractionalPart(): bool
{
if ($this->scale === 0) {
return false;
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, -$this->scale) !== str_repeat('0', $this->scale);
}
#[Override]
public function toBigInteger(): BigInteger
{
$zeroScaleDecimal = $this->scale === 0 ? $this : $this->dividedBy(1, 0);
return self::newBigInteger($zeroScaleDecimal->value);
}
#[Override]
public function toBigDecimal(): BigDecimal
{
return $this;
}
#[Override]
public function toBigRational(): BigRational
{
$numerator = self::newBigInteger($this->value);
$denominator = self::newBigInteger('1' . str_repeat('0', $this->scale));
return self::newBigRational($numerator, $denominator, false);
}
#[Override]
public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
if ($scale === $this->scale) {
return $this;
}
return $this->dividedBy(BigDecimal::one(), $scale, $roundingMode);
}
#[Override]
public function toInt(): int
{
return $this->toBigInteger()->toInt();
}
#[Override]
public function toFloat(): float
{
return (float) $this->toString();
}
/**
* @return numeric-string
*/
#[Override]
public function toString(): string
{
if ($this->scale === 0) {
/** @var numeric-string */
return $this->value;
}
$value = $this->getUnscaledValueWithLeadingZeros();
/** @phpstan-ignore return.type */
return substr($value, 0, -$this->scale) . '.' . substr($value, -$this->scale);
}
/**
* This method is required for serializing the object and SHOULD NOT be accessed directly.
*
* @internal
*
* @return array{value: string, scale: int}
*/
public function __serialize(): array
{
return ['value' => $this->value, 'scale' => $this->scale];
}
/**
* This method is only here to allow unserializing the object and cannot be accessed directly.
*
* @internal
*
* @param array{value: string, scale: int} $data
*
* @throws LogicException
*/
public function __unserialize(array $data): void
{
/** @phpstan-ignore isset.initializedProperty */
if (isset($this->value)) {
throw new LogicException('__unserialize() is an internal function, it must not be called directly.');
}
/** @phpstan-ignore deadCode.unreachable */
$this->value = $data['value'];
$this->scale = $data['scale'];
}
#[Override]
protected static function from(BigNumber $number): static
{
return $number->toBigDecimal();
}
/**
* Puts the internal values of the given decimal numbers on the same scale.
*
* @return array{string, string} The scaled integer values of $x and $y.
*
* @pure
*/
private function scaleValues(BigDecimal $x, BigDecimal $y): array
{
$a = $x->value;
$b = $y->value;
if ($b !== '0' && $x->scale > $y->scale) {
$b .= str_repeat('0', $x->scale - $y->scale);
} elseif ($a !== '0' && $x->scale < $y->scale) {
$a .= str_repeat('0', $y->scale - $x->scale);
}
return [$a, $b];
}
/**
* @pure
*/
private function valueWithMinScale(int $scale): string
{
$value = $this->value;
if ($this->value !== '0' && $scale > $this->scale) {
$value .= str_repeat('0', $scale - $this->scale);
}
return $value;
}
/**
* Adds leading zeros if necessary to the unscaled value to represent the full decimal number.
*
* @pure
*/
private function getUnscaledValueWithLeadingZeros(): string
{
$value = $this->value;
$targetLength = $this->scale + 1;
$negative = ($value[0] === '-');
$length = strlen($value);
if ($negative) {
$length--;
}
if ($length >= $targetLength) {
return $this->value;
}
if ($negative) {
$value = substr($value, 1);
}
$value = str_pad($value, $targetLength, '0', STR_PAD_LEFT);
if ($negative) {
$value = '-' . $value;
}
return $value;
}
}
File diff suppressed because it is too large Load Diff
+712
View File
@@ -0,0 +1,712 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use InvalidArgumentException;
use JsonSerializable;
use Override;
use Stringable;
use function array_shift;
use function assert;
use function filter_var;
use function is_float;
use function is_int;
use function is_nan;
use function is_null;
use function ltrim;
use function preg_match;
use function str_contains;
use function str_repeat;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
use const FILTER_VALIDATE_INT;
use const PREG_UNMATCHED_AS_NULL;
/**
* Base class for arbitrary-precision numbers.
*
* This class is sealed: it is part of the public API but should not be subclassed in userland.
* Protected methods may change in any version.
*
* @phpstan-sealed BigInteger|BigDecimal|BigRational
*/
abstract readonly class BigNumber implements JsonSerializable, Stringable
{
/**
* The regular expression used to parse integer or decimal numbers.
*/
private const PARSE_REGEXP_NUMERICAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<integral>[0-9]+)?' .
'(?<point>\.)?' .
'(?<fractional>[0-9]+)?' .
'(?:[eE](?<exponent>[\-\+]?[0-9]+))?' .
'$/';
/**
* The regular expression used to parse rational numbers.
*/
private const PARSE_REGEXP_RATIONAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<numerator>[0-9]+)' .
'\/' .
'(?<denominator>[0-9]+)' .
'$/';
/**
* Creates a BigNumber of the given value.
*
* When of() is called on BigNumber, the concrete return type is dependent on the given value, with the following
* rules:
*
* - BigNumber instances are returned as is
* - integer numbers are returned as BigInteger
* - floating point numbers are converted to a string then parsed as such (deprecated, will be removed in 0.15)
* - strings containing a `/` character are returned as BigRational
* - strings containing a `.` character or using an exponential notation are returned as BigDecimal
* - strings containing only digits with an optional leading `+` or `-` sign are returned as BigInteger
*
* When of() is called on BigInteger, BigDecimal, or BigRational, the resulting number is converted to an instance
* of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown.
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function of(BigNumber|int|float|string $value): static
{
$value = self::_of($value);
if (static::class === BigNumber::class) {
assert($value instanceof static);
return $value;
}
return static::from($value);
}
/**
* Creates a BigNumber of the given value, or returns null if the input is null.
*
* Behaves like of() for non-null values.
*
* @see BigNumber::of()
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function ofNullable(BigNumber|int|float|string|null $value): ?static
{
if (is_null($value)) {
return null;
}
return static::of($value);
}
/**
* Returns the minimum of the given values.
*
* If several values are equal and minimal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|float|string ...$values The numbers to compare. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function min(BigNumber|int|float|string ...$values): static
{
$min = null;
foreach ($values as $value) {
$value = static::of($value);
if ($min === null || $value->isLessThan($min)) {
$min = $value;
}
}
if ($min === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
return $min;
}
/**
* Returns the maximum of the given values.
*
* If several values are equal and maximal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|float|string ...$values The numbers to compare. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function max(BigNumber|int|float|string ...$values): static
{
$max = null;
foreach ($values as $value) {
$value = static::of($value);
if ($max === null || $value->isGreaterThan($max)) {
$max = $value;
}
}
if ($max === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
return $max;
}
/**
* Returns the sum of the given values.
*
* When called on BigNumber, sum() accepts any supported type and returns a result whose type is the widest among
* the given values (BigInteger < BigDecimal < BigRational).
*
* When called on BigInteger, BigDecimal, or BigRational, sum() requires that all values can be converted to that
* specific subclass, and returns a result of the same type.
*
* @param BigNumber|int|float|string ...$values The numbers to add. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function sum(BigNumber|int|float|string ...$values): static
{
$first = array_shift($values);
if ($first === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
$sum = static::of($first);
foreach ($values as $value) {
$sum = self::add($sum, static::of($value));
}
assert($sum instanceof static);
return $sum;
}
/**
* Checks if this number is equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) === 0;
}
/**
* Checks if this number is strictly less than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThan(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) < 0;
}
/**
* Checks if this number is less than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThanOrEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) <= 0;
}
/**
* Checks if this number is strictly greater than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThan(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) > 0;
}
/**
* Checks if this number is greater than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThanOrEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) >= 0;
}
/**
* Checks if this number equals zero.
*
* @pure
*/
final public function isZero(): bool
{
return $this->getSign() === 0;
}
/**
* Checks if this number is strictly negative.
*
* @pure
*/
final public function isNegative(): bool
{
return $this->getSign() < 0;
}
/**
* Checks if this number is negative or zero.
*
* @pure
*/
final public function isNegativeOrZero(): bool
{
return $this->getSign() <= 0;
}
/**
* Checks if this number is strictly positive.
*
* @pure
*/
final public function isPositive(): bool
{
return $this->getSign() > 0;
}
/**
* Checks if this number is positive or zero.
*
* @pure
*/
final public function isPositiveOrZero(): bool
{
return $this->getSign() >= 0;
}
/**
* Returns the absolute value of this number.
*
* @pure
*/
final public function abs(): static
{
return $this->isNegative() ? $this->negated() : $this;
}
/**
* Returns the negated value of this number.
*
* @pure
*/
abstract public function negated(): static;
/**
* Returns the sign of this number.
*
* Returns -1 if the number is negative, 0 if zero, 1 if positive.
*
* @return -1|0|1
*
* @pure
*/
abstract public function getSign(): int;
/**
* Compares this number to the given one.
*
* Returns -1 if `$this` is lower than, 0 if equal to, 1 if greater than `$that`.
*
* @return -1|0|1
*
* @throws MathException If the number is not valid.
*
* @pure
*/
abstract public function compareTo(BigNumber|int|float|string $that): int;
/**
* Limits (clamps) this number between the given minimum and maximum values.
*
* If the number is lower than $min, returns $min.
* If the number is greater than $max, returns $max.
* Otherwise, returns this number unchanged.
*
* @param BigNumber|int|float|string $min The minimum. Must be convertible to an instance of the class this method is called on.
* @param BigNumber|int|float|string $max The maximum. Must be convertible to an instance of the class this method is called on.
*
* @throws MathException If min/max are not convertible to an instance of the class this method is called on.
* @throws InvalidArgumentException If min is greater than max.
*
* @pure
*/
final public function clamp(BigNumber|int|float|string $min, BigNumber|int|float|string $max): static
{
$min = static::of($min);
$max = static::of($max);
if ($min->isGreaterThan($max)) {
throw new InvalidArgumentException('Minimum value must be less than or equal to maximum value.');
}
if ($this->isLessThan($min)) {
return $min;
}
if ($this->isGreaterThan($max)) {
return $max;
}
return $this;
}
/**
* Converts this number to a BigInteger.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigInteger without rounding.
*
* @pure
*/
abstract public function toBigInteger(): BigInteger;
/**
* Converts this number to a BigDecimal.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigDecimal without rounding.
*
* @pure
*/
abstract public function toBigDecimal(): BigDecimal;
/**
* Converts this number to a BigRational.
*
* @pure
*/
abstract public function toBigRational(): BigRational;
/**
* Converts this number to a BigDecimal with the given scale, using rounding if necessary.
*
* @param int $scale The scale of the resulting `BigDecimal`. Must be non-negative.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used, and this number cannot be converted to
* the given scale without rounding.
*
* @pure
*/
abstract public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal;
/**
* Returns the exact value of this number as a native integer.
*
* If this number cannot be converted to a native integer without losing precision, an exception is thrown.
* Note that the acceptable range for an integer depends on the platform and differs for 32-bit and 64-bit.
*
* @throws MathException If this number cannot be exactly converted to a native integer.
*
* @pure
*/
abstract public function toInt(): int;
/**
* Returns an approximation of this number as a floating-point value.
*
* Note that this method can discard information as the precision of a floating-point value
* is inherently limited.
*
* If the number is greater than the largest representable floating point number, positive infinity is returned.
* If the number is less than the smallest representable floating point number, negative infinity is returned.
* This method never returns NaN.
*
* @pure
*/
abstract public function toFloat(): float;
/**
* Returns a string representation of this number.
*
* The output of this method can be parsed by the `of()` factory method; this will yield an object equal to this
* one, but possibly of a different type if instantiated through `BigNumber::of()`.
*
* @pure
*/
abstract public function toString(): string;
#[Override]
final public function jsonSerialize(): string
{
return $this->toString();
}
/**
* @pure
*/
final public function __toString(): string
{
return $this->toString();
}
/**
* Overridden by subclasses to convert a BigNumber to an instance of the subclass.
*
* @throws RoundingNecessaryException If the value cannot be converted.
*
* @pure
*/
abstract protected static function from(BigNumber $number): static;
/**
* Proxy method to access BigInteger's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigInteger(string $value): BigInteger
{
return new BigInteger($value);
}
/**
* Proxy method to access BigDecimal's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigDecimal(string $value, int $scale = 0): BigDecimal
{
return new BigDecimal($value, $scale);
}
/**
* Proxy method to access BigRational's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigRational(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator): BigRational
{
return new BigRational($numerator, $denominator, $checkDenominator);
}
/**
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
*
* @pure
*/
private static function _of(BigNumber|int|float|string $value): BigNumber
{
if ($value instanceof BigNumber) {
return $value;
}
if (is_int($value)) {
return new BigInteger((string) $value);
}
if (is_float($value)) {
// @phpstan-ignore-next-line
trigger_error(
'Passing floats to BigNumber::of() and arithmetic methods is deprecated and will be removed in 0.15. ' .
'Cast the float to string explicitly to preserve the previous behaviour.',
E_USER_DEPRECATED,
);
if (is_nan($value)) {
$value = 'NAN';
} else {
$value = (string) $value;
}
}
if (str_contains($value, '/')) {
// Rational number
if (preg_match(self::PARSE_REGEXP_RATIONAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$numerator = $matches['numerator'];
$denominator = $matches['denominator'];
$numerator = self::cleanUp($sign, $numerator);
$denominator = self::cleanUp(null, $denominator);
if ($denominator === '0') {
throw DivisionByZeroException::denominatorMustNotBeZero();
}
return new BigRational(
new BigInteger($numerator),
new BigInteger($denominator),
false,
);
} else {
// Integer or decimal number
if (preg_match(self::PARSE_REGEXP_NUMERICAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$point = $matches['point'];
$integral = $matches['integral'];
$fractional = $matches['fractional'];
$exponent = $matches['exponent'];
if ($integral === null && $fractional === null) {
throw NumberFormatException::invalidFormat($value);
}
if ($integral === null) {
$integral = '0';
}
if ($point !== null || $exponent !== null) {
$fractional ??= '';
if ($exponent !== null) {
if ($exponent[0] === '-') {
$exponent = ltrim(substr($exponent, 1), '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
if ($exponent !== false) {
$exponent = -$exponent;
}
} else {
if ($exponent[0] === '+') {
$exponent = substr($exponent, 1);
}
$exponent = ltrim($exponent, '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
}
} else {
$exponent = 0;
}
if ($exponent === false) {
throw new NumberFormatException('Exponent too large.');
}
$unscaledValue = self::cleanUp($sign, $integral . $fractional);
$scale = strlen($fractional) - $exponent;
if ($scale < 0) {
if ($unscaledValue !== '0') {
$unscaledValue .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($unscaledValue, $scale);
}
$integral = self::cleanUp($sign, $integral);
return new BigInteger($integral);
}
}
/**
* Removes optional leading zeros and applies sign.
*
* @param string|null $sign The sign, '+' or '-', optional. Null is allowed for convenience and treated as '+'.
* @param string $number The number, validated as a string of digits.
*
* @pure
*/
private static function cleanUp(string|null $sign, string $number): string
{
$number = ltrim($number, '0');
if ($number === '') {
return '0';
}
return $sign === '-' ? '-' . $number : $number;
}
/**
* Adds two BigNumber instances in the correct order to avoid a RoundingNecessaryException.
*
* @pure
*/
private static function add(BigNumber $a, BigNumber $b): BigNumber
{
if ($a instanceof BigRational) {
return $a->plus($b);
}
if ($b instanceof BigRational) {
return $b->plus($a);
}
if ($a instanceof BigDecimal) {
return $a->plus($b);
}
if ($b instanceof BigDecimal) {
return $b->plus($a);
}
return $a->plus($b);
}
}
+606
View File
@@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use InvalidArgumentException;
use LogicException;
use Override;
use function is_finite;
use function max;
use function min;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
/**
* An arbitrarily large rational number.
*
* This class is immutable.
*
* Fractions are automatically simplified to lowest terms. For example, `2/4` becomes `1/2`.
* The denominator is always strictly positive; the sign is carried by the numerator.
*/
final readonly class BigRational extends BigNumber
{
/**
* The numerator.
*/
private BigInteger $numerator;
/**
* The denominator. Always strictly positive.
*/
private BigInteger $denominator;
/**
* Protected constructor. Use a factory method to obtain an instance.
*
* @param BigInteger $numerator The numerator.
* @param BigInteger $denominator The denominator.
* @param bool $checkDenominator Whether to check the denominator for negative and zero.
*
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
protected function __construct(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator)
{
if ($checkDenominator) {
if ($denominator->isZero()) {
throw DivisionByZeroException::denominatorMustNotBeZero();
}
if ($denominator->isNegative()) {
$numerator = $numerator->negated();
$denominator = $denominator->negated();
}
}
$this->numerator = $numerator;
$this->denominator = $denominator;
}
/**
* Creates a BigRational out of a numerator and a denominator.
*
* If the denominator is negative, the signs of both the numerator and the denominator
* will be inverted to ensure that the denominator is always positive.
*
* @deprecated Use ofFraction() instead.
*
* @param BigNumber|int|float|string $numerator The numerator. Must be convertible to a BigInteger.
* @param BigNumber|int|float|string $denominator The denominator. Must be convertible to a BigInteger.
*
* @throws NumberFormatException If an argument does not represent a valid number.
* @throws RoundingNecessaryException If an argument represents a non-integer number.
* @throws DivisionByZeroException If the denominator is zero.
*/
public static function nd(
BigNumber|int|float|string $numerator,
BigNumber|int|float|string $denominator,
): BigRational {
trigger_error(
'The BigRational::nd() method is deprecated, use BigRational::ofFraction() instead.',
E_USER_DEPRECATED,
);
return self::ofFraction($numerator, $denominator);
}
/**
* Creates a BigRational out of a numerator and a denominator.
*
* If the denominator is negative, the signs of both the numerator and the denominator
* will be inverted to ensure that the denominator is always positive.
*
* @param BigNumber|int|float|string $numerator The numerator. Must be convertible to a BigInteger.
* @param BigNumber|int|float|string $denominator The denominator. Must be convertible to a BigInteger.
*
* @throws MathException If an argument is not valid, or is not convertible to a BigInteger.
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
public static function ofFraction(
BigNumber|int|float|string $numerator,
BigNumber|int|float|string $denominator,
): BigRational {
$numerator = BigInteger::of($numerator);
$denominator = BigInteger::of($denominator);
return new BigRational($numerator, $denominator, true);
}
/**
* Returns a BigRational representing zero.
*
* @pure
*/
public static function zero(): BigRational
{
/** @var BigRational|null $zero */
static $zero;
if ($zero === null) {
$zero = new BigRational(BigInteger::zero(), BigInteger::one(), false);
}
return $zero;
}
/**
* Returns a BigRational representing one.
*
* @pure
*/
public static function one(): BigRational
{
/** @var BigRational|null $one */
static $one;
if ($one === null) {
$one = new BigRational(BigInteger::one(), BigInteger::one(), false);
}
return $one;
}
/**
* Returns a BigRational representing ten.
*
* @pure
*/
public static function ten(): BigRational
{
/** @var BigRational|null $ten */
static $ten;
if ($ten === null) {
$ten = new BigRational(BigInteger::ten(), BigInteger::one(), false);
}
return $ten;
}
/**
* @pure
*/
public function getNumerator(): BigInteger
{
return $this->numerator;
}
/**
* @pure
*/
public function getDenominator(): BigInteger
{
return $this->denominator;
}
/**
* Returns the quotient of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use getIntegralPart() instead.
*/
public function quotient(): BigInteger
{
trigger_error(
'BigRational::quotient() is deprecated and will be removed in 0.15. Use getIntegralPart() instead.',
E_USER_DEPRECATED,
);
return $this->numerator->quotient($this->denominator);
}
/**
* Returns the remainder of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use `$number->getNumerator()->remainder($number->getDenominator())` instead.
*/
public function remainder(): BigInteger
{
trigger_error(
'BigRational::remainder() is deprecated and will be removed in 0.15. Use `$number->getNumerator()->remainder($number->getDenominator())` instead.',
E_USER_DEPRECATED,
);
return $this->numerator->remainder($this->denominator);
}
/**
* Returns the quotient and remainder of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead.
*
* @return array{BigInteger, BigInteger}
*/
public function quotientAndRemainder(): array
{
trigger_error(
'BigRational::quotientAndRemainder() is deprecated and will be removed in 0.15. Use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead.',
E_USER_DEPRECATED,
);
return $this->numerator->quotientAndRemainder($this->denominator);
}
/**
* Returns the integral part of this rational number.
*
* Examples:
*
* - `7/3` returns `2` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-2` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`.
*
* @pure
*/
public function getIntegralPart(): BigInteger
{
return $this->numerator->quotient($this->denominator);
}
/**
* Returns the fractional part of this rational number.
*
* Examples:
*
* - `7/3` returns `1/3` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-1/3` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`.
*
* @pure
*/
public function getFractionalPart(): BigRational
{
return new BigRational($this->numerator->remainder($this->denominator), $this->denominator, false);
}
/**
* Returns the sum of this number and the given one.
*
* @param BigNumber|int|float|string $that The number to add.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function plus(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->plus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the difference of this number and the given one.
*
* @param BigNumber|int|float|string $that The number to subtract.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function minus(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->minus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the product of this number and the given one.
*
* @param BigNumber|int|float|string $that The multiplier.
*
* @throws MathException If the multiplier is not valid.
*
* @pure
*/
public function multipliedBy(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->numerator);
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the result of the division of this number by the given one.
*
* @param BigNumber|int|float|string $that The divisor.
*
* @throws MathException If the divisor is not valid.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function dividedBy(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$numerator = $this->numerator->multipliedBy($that->denominator);
$denominator = $this->denominator->multipliedBy($that->numerator);
return new BigRational($numerator, $denominator, true);
}
/**
* Returns this number exponentiated to the given value.
*
* @throws InvalidArgumentException If the exponent is not in the range 0 to 1,000,000.
*
* @pure
*/
public function power(int $exponent): BigRational
{
if ($exponent === 0) {
return BigRational::one();
}
if ($exponent === 1) {
return $this;
}
return new BigRational(
$this->numerator->power($exponent),
$this->denominator->power($exponent),
false,
);
}
/**
* Returns the reciprocal of this BigRational.
*
* The reciprocal has the numerator and denominator swapped.
*
* @throws DivisionByZeroException If the numerator is zero.
*
* @pure
*/
public function reciprocal(): BigRational
{
return new BigRational($this->denominator, $this->numerator, true);
}
#[Override]
public function negated(): static
{
return new BigRational($this->numerator->negated(), $this->denominator, false);
}
/**
* Returns the simplified value of this BigRational.
*
* @pure
*/
public function simplified(): BigRational
{
$gcd = $this->numerator->gcd($this->denominator);
$numerator = $this->numerator->quotient($gcd);
$denominator = $this->denominator->quotient($gcd);
return new BigRational($numerator, $denominator, false);
}
#[Override]
public function compareTo(BigNumber|int|float|string $that): int
{
$that = BigRational::of($that);
if ($this->denominator->isEqualTo($that->denominator)) {
return $this->numerator->compareTo($that->numerator);
}
return $this->numerator
->multipliedBy($that->denominator)
->compareTo($that->numerator->multipliedBy($this->denominator));
}
#[Override]
public function getSign(): int
{
return $this->numerator->getSign();
}
#[Override]
public function toBigInteger(): BigInteger
{
$simplified = $this->simplified();
if (! $simplified->denominator->isEqualTo(1)) {
throw new RoundingNecessaryException('This rational number cannot be represented as an integer value without rounding.');
}
return $simplified->numerator;
}
#[Override]
public function toBigDecimal(): BigDecimal
{
return $this->numerator->toBigDecimal()->dividedByExact($this->denominator);
}
#[Override]
public function toBigRational(): BigRational
{
return $this;
}
#[Override]
public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
return $this->numerator->toBigDecimal()->dividedBy($this->denominator, $scale, $roundingMode);
}
#[Override]
public function toInt(): int
{
return $this->toBigInteger()->toInt();
}
#[Override]
public function toFloat(): float
{
$simplified = $this->simplified();
$numeratorFloat = $simplified->numerator->toFloat();
$denominatorFloat = $simplified->denominator->toFloat();
if (is_finite($numeratorFloat) && is_finite($denominatorFloat)) {
return $numeratorFloat / $denominatorFloat;
}
// At least one side overflows to INF; use a decimal approximation instead.
// We need ~17 significant digits for double precision (we use 20 for some margin). Since $scale controls
// decimal places (not significant digits), we subtract the estimated order of magnitude so that large results
// use fewer decimal places and small results use more (to look past leading zeros). Clamped to [0, 350] as
// doubles range from e-324 to e308 (350 ≈ 324 + 20 significant digits + margin).
$magnitude = strlen($simplified->numerator->abs()->toString()) - strlen($simplified->denominator->toString());
$scale = min(350, max(0, 20 - $magnitude));
return $simplified->numerator
->toBigDecimal()
->dividedBy($simplified->denominator, $scale, RoundingMode::HalfEven)
->toFloat();
}
#[Override]
public function toString(): string
{
$numerator = $this->numerator->toString();
$denominator = $this->denominator->toString();
if ($denominator === '1') {
return $numerator;
}
return $numerator . '/' . $denominator;
}
/**
* Returns the decimal representation of this rational number, with repeating decimals in parentheses.
*
* WARNING: This method is unbounded.
* The length of the repeating decimal period can be as large as `denominator - 1`.
* For fractions with large denominators, this method can use excessive memory and CPU time.
* For example, `1/100019` has a repeating period of 100,018 digits.
*
* Examples:
*
* - `10/3` returns `3.(3)`
* - `171/70` returns `2.4(428571)`
* - `1/2` returns `0.5`
*
* @pure
*/
public function toRepeatingDecimalString(): string
{
if ($this->numerator->isZero()) {
return '0';
}
$sign = $this->numerator->isNegative() ? '-' : '';
$numerator = $this->numerator->abs();
$denominator = $this->denominator;
$integral = $numerator->quotient($denominator);
$remainder = $numerator->remainder($denominator);
$integralString = $integral->toString();
if ($remainder->isZero()) {
return $sign . $integralString;
}
$digits = '';
$remainderPositions = [];
$index = 0;
while (! $remainder->isZero()) {
$remainderString = $remainder->toString();
if (isset($remainderPositions[$remainderString])) {
$repeatIndex = $remainderPositions[$remainderString];
$nonRepeating = substr($digits, 0, $repeatIndex);
$repeating = substr($digits, $repeatIndex);
return $sign . $integralString . '.' . $nonRepeating . '(' . $repeating . ')';
}
$remainderPositions[$remainderString] = $index;
$remainder = $remainder->multipliedBy(10);
$digits .= $remainder->quotient($denominator)->toString();
$remainder = $remainder->remainder($denominator);
$index++;
}
return $sign . $integralString . '.' . $digits;
}
/**
* This method is required for serializing the object and SHOULD NOT be accessed directly.
*
* @internal
*
* @return array{numerator: BigInteger, denominator: BigInteger}
*/
public function __serialize(): array
{
return ['numerator' => $this->numerator, 'denominator' => $this->denominator];
}
/**
* This method is only here to allow unserializing the object and cannot be accessed directly.
*
* @internal
*
* @param array{numerator: BigInteger, denominator: BigInteger} $data
*
* @throws LogicException
*/
public function __unserialize(array $data): void
{
/** @phpstan-ignore isset.initializedProperty */
if (isset($this->numerator)) {
throw new LogicException('__unserialize() is an internal function, it must not be called directly.');
}
/** @phpstan-ignore deadCode.unreachable */
$this->numerator = $data['numerator'];
$this->denominator = $data['denominator'];
}
#[Override]
protected static function from(BigNumber $number): static
{
return $number->toBigRational();
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when a division by zero occurs.
*/
final class DivisionByZeroException extends MathException
{
/**
* @pure
*/
public static function divisionByZero(): DivisionByZeroException
{
return new self('Division by zero.');
}
/**
* @pure
*/
public static function modulusMustNotBeZero(): DivisionByZeroException
{
return new self('The modulus must not be zero.');
}
/**
* @pure
*/
public static function denominatorMustNotBeZero(): DivisionByZeroException
{
return new self('The denominator of a rational number cannot be zero.');
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use Brick\Math\BigInteger;
use function sprintf;
use const PHP_INT_MAX;
use const PHP_INT_MIN;
/**
* Exception thrown when an integer overflow occurs.
*/
final class IntegerOverflowException extends MathException
{
/**
* @pure
*/
public static function toIntOverflow(BigInteger $value): IntegerOverflowException
{
$message = '%s is out of range %d to %d and cannot be represented as an integer.';
return new self(sprintf($message, $value->toString(), PHP_INT_MIN, PHP_INT_MAX));
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Base class for all math exceptions.
*/
class MathException extends RuntimeException
{
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when attempting to perform an unsupported operation, such as a square root, on a negative number.
*/
final class NegativeNumberException extends MathException
{
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use function dechex;
use function ord;
use function sprintf;
use function strtoupper;
/**
* Exception thrown when attempting to create a number from a string with an invalid format.
*/
final class NumberFormatException extends MathException
{
/**
* @pure
*/
public static function invalidFormat(string $value): self
{
return new self(sprintf(
'The given value "%s" does not represent a valid number.',
$value,
));
}
/**
* @param string $char The failing character.
*
* @pure
*/
public static function charNotInAlphabet(string $char): self
{
return new self(sprintf(
'Character %s is not valid in the given alphabet.',
self::charToString($char),
));
}
/**
* @pure
*/
private static function charToString(string $char): string
{
$ord = ord($char);
if ($ord < 32 || $ord > 126) {
$char = strtoupper(dechex($ord));
if ($ord < 16) {
$char = '0' . $char;
}
return '0x' . $char;
}
return '"' . $char . '"';
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when a number cannot be represented at the requested scale without rounding.
*/
final class RoundingNecessaryException extends MathException
{
/**
* @pure
*/
public static function roundingNecessary(): RoundingNecessaryException
{
return new self('Rounding is necessary to represent the result of the operation at this scale.');
}
}
+704
View File
@@ -0,0 +1,704 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\RoundingMode;
use function chr;
use function ltrim;
use function ord;
use function str_repeat;
use function strlen;
use function strpos;
use function strrev;
use function strtolower;
use function substr;
/**
* Performs basic operations on arbitrary size integers.
*
* Unless otherwise specified, all parameters must be validated as non-empty strings of digits,
* without leading zero, and with an optional leading minus sign if the number is not zero.
*
* Any other parameter format will lead to undefined behaviour.
* All methods must return strings respecting this format, unless specified otherwise.
*
* @internal
*/
abstract readonly class Calculator
{
/**
* The maximum exponent value allowed for the pow() method.
*/
public const MAX_POWER = 1_000_000;
/**
* The alphabet for converting from and to base 2 to 36, lowercase.
*/
public const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* Returns the absolute value of a number.
*
* @pure
*/
final public function abs(string $n): string
{
return ($n[0] === '-') ? substr($n, 1) : $n;
}
/**
* Negates a number.
*
* @pure
*/
final public function neg(string $n): string
{
if ($n === '0') {
return '0';
}
if ($n[0] === '-') {
return substr($n, 1);
}
return '-' . $n;
}
/**
* Compares two numbers.
*
* Returns -1 if the first number is less than, 0 if equal to, 1 if greater than the second number.
*
* @return -1|0|1
*
* @pure
*/
final public function cmp(string $a, string $b): int
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
if ($aNeg && ! $bNeg) {
return -1;
}
if ($bNeg && ! $aNeg) {
return 1;
}
$aLen = strlen($aDig);
$bLen = strlen($bDig);
if ($aLen < $bLen) {
$result = -1;
} elseif ($aLen > $bLen) {
$result = 1;
} else {
$result = $aDig <=> $bDig;
}
return $aNeg ? -$result : $result;
}
/**
* Adds two numbers.
*
* @pure
*/
abstract public function add(string $a, string $b): string;
/**
* Subtracts two numbers.
*
* @pure
*/
abstract public function sub(string $a, string $b): string;
/**
* Multiplies two numbers.
*
* @pure
*/
abstract public function mul(string $a, string $b): string;
/**
* Returns the quotient of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The quotient.
*
* @pure
*/
abstract public function divQ(string $a, string $b): string;
/**
* Returns the remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The remainder.
*
* @pure
*/
abstract public function divR(string $a, string $b): string;
/**
* Returns the quotient and remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return array{string, string} An array containing the quotient and remainder.
*
* @pure
*/
abstract public function divQR(string $a, string $b): array;
/**
* Exponentiates a number.
*
* @param string $a The base number.
* @param int $e The exponent, validated as an integer between 0 and MAX_POWER.
*
* @return string The power.
*
* @pure
*/
abstract public function pow(string $a, int $e): string;
/**
* @param string $b The modulus; must not be zero.
*
* @pure
*/
public function mod(string $a, string $b): string
{
return $this->divR($this->add($this->divR($a, $b), $b), $b);
}
/**
* Returns the modular multiplicative inverse of $x modulo $m.
*
* If $x has no multiplicative inverse mod m, this method must return null.
*
* This method can be overridden by the concrete implementation if the underlying library has built-in support.
*
* @param string $m The modulus; must not be negative or zero.
*
* @pure
*/
public function modInverse(string $x, string $m): ?string
{
if ($m === '1') {
return '0';
}
$modVal = $x;
if ($x[0] === '-' || ($this->cmp($this->abs($x), $m) >= 0)) {
$modVal = $this->mod($x, $m);
}
[$g, $x] = $this->gcdExtended($modVal, $m);
if ($g !== '1') {
return null;
}
return $this->mod($this->add($this->mod($x, $m), $m), $m);
}
/**
* Raises a number into power with modulo.
*
* @param string $base The base number.
* @param string $exp The exponent; must be positive or zero.
* @param string $mod The modulus; must be strictly positive.
*
* @pure
*/
abstract public function modPow(string $base, string $exp, string $mod): string;
/**
* Returns the greatest common divisor of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for GCD calculations.
*
* @return string The GCD, always positive, or zero if both arguments are zero.
*
* @pure
*/
public function gcd(string $a, string $b): string
{
if ($a === '0') {
return $this->abs($b);
}
if ($b === '0') {
return $this->abs($a);
}
return $this->gcd($b, $this->divR($a, $b));
}
/**
* Returns the least common multiple of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for LCM calculations.
*
* @return string The LCM, always positive, or zero if at least one argument is zero.
*
* @pure
*/
public function lcm(string $a, string $b): string
{
if ($a === '0' || $b === '0') {
return '0';
}
return $this->divQ($this->abs($this->mul($a, $b)), $this->gcd($a, $b));
}
/**
* Returns the square root of the given number, rounded down.
*
* The result is the largest x such that x² ≤ n.
* The input MUST NOT be negative.
*
* @pure
*/
abstract public function sqrt(string $n): string;
/**
* Converts a number from an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number, positive or zero, non-empty, case-insensitively validated for the given base.
* @param int $base The base of the number, validated from 2 to 36.
*
* @return string The converted number, following the Calculator conventions.
*
* @pure
*/
public function fromBase(string $number, int $base): string
{
return $this->fromArbitraryBase(strtolower($number), self::ALPHABET, $base);
}
/**
* Converts a number to an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number to convert, following the Calculator conventions.
* @param int $base The base to convert to, validated from 2 to 36.
*
* @return string The converted number, lowercase.
*
* @pure
*/
public function toBase(string $number, int $base): string
{
$negative = ($number[0] === '-');
if ($negative) {
$number = substr($number, 1);
}
$number = $this->toArbitraryBase($number, self::ALPHABET, $base);
if ($negative) {
return '-' . $number;
}
return $number;
}
/**
* Converts a non-negative number in an arbitrary base using a custom alphabet, to base 10.
*
* @param string $number The number to convert, validated as a non-empty string,
* containing only chars in the given alphabet/base.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base of the number, validated from 2 to alphabet length.
*
* @return string The number in base 10, following the Calculator conventions.
*
* @pure
*/
final public function fromArbitraryBase(string $number, string $alphabet, int $base): string
{
// remove leading "zeros"
$number = ltrim($number, $alphabet[0]);
if ($number === '') {
return '0';
}
// optimize for "one"
if ($number === $alphabet[1]) {
return '1';
}
$result = '0';
$power = '1';
$base = (string) $base;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$index = strpos($alphabet, $number[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, $base);
}
}
return $result;
}
/**
* Converts a non-negative number to an arbitrary base using a custom alphabet.
*
* @param string $number The number to convert, positive or zero, following the Calculator conventions.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base to convert to, validated from 2 to alphabet length.
*
* @return string The converted number in the given alphabet.
*
* @pure
*/
final public function toArbitraryBase(string $number, string $alphabet, int $base): string
{
if ($number === '0') {
return $alphabet[0];
}
$base = (string) $base;
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, $base);
$remainder = (int) $remainder;
$result .= $alphabet[$remainder];
}
return strrev($result);
}
/**
* Performs a rounded division.
*
* Rounding is performed when the remainder of the division is not zero.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
* @param RoundingMode $roundingMode The rounding mode.
*
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is provided but rounding is necessary.
*
* @pure
*/
final public function divRound(string $a, string $b, RoundingMode $roundingMode): string
{
[$quotient, $remainder] = $this->divQR($a, $b);
$hasDiscardedFraction = ($remainder !== '0');
$isPositiveOrZero = ($a[0] === '-') === ($b[0] === '-');
$discardedFractionSign = function () use ($remainder, $b): int {
$r = $this->abs($this->mul($remainder, '2'));
$b = $this->abs($b);
return $this->cmp($r, $b);
};
$increment = false;
switch ($roundingMode) {
case RoundingMode::Unnecessary:
if ($hasDiscardedFraction) {
throw RoundingNecessaryException::roundingNecessary();
}
break;
case RoundingMode::Up:
$increment = $hasDiscardedFraction;
break;
case RoundingMode::Down:
break;
case RoundingMode::Ceiling:
$increment = $hasDiscardedFraction && $isPositiveOrZero;
break;
case RoundingMode::Floor:
$increment = $hasDiscardedFraction && ! $isPositiveOrZero;
break;
case RoundingMode::HalfUp:
$increment = $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfDown:
$increment = $discardedFractionSign() > 0;
break;
case RoundingMode::HalfCeiling:
$increment = $isPositiveOrZero ? $discardedFractionSign() >= 0 : $discardedFractionSign() > 0;
break;
case RoundingMode::HalfFloor:
$increment = $isPositiveOrZero ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfEven:
$lastDigit = (int) $quotient[-1];
$lastDigitIsEven = ($lastDigit % 2 === 0);
$increment = $lastDigitIsEven ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
}
if ($increment) {
return $this->add($quotient, $isPositiveOrZero ? '1' : '-1');
}
return $quotient;
}
/**
* Calculates bitwise AND of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function and(string $a, string $b): string
{
return $this->bitwise('and', $a, $b);
}
/**
* Calculates bitwise OR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function or(string $a, string $b): string
{
return $this->bitwise('or', $a, $b);
}
/**
* Calculates bitwise XOR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function xor(string $a, string $b): string
{
return $this->bitwise('xor', $a, $b);
}
/**
* Extracts the sign & digits of the operands.
*
* @return array{bool, bool, string, string} Whether $a and $b are negative, followed by their digits.
*
* @pure
*/
final protected function init(string $a, string $b): array
{
return [
$aNeg = ($a[0] === '-'),
$bNeg = ($b[0] === '-'),
$aNeg ? substr($a, 1) : $a,
$bNeg ? substr($b, 1) : $b,
];
}
/**
* @return array{string, string, string} GCD, X, Y
*
* @pure
*/
private function gcdExtended(string $a, string $b): array
{
if ($a === '0') {
return [$b, '0', '1'];
}
[$gcd, $x1, $y1] = $this->gcdExtended($this->mod($b, $a), $a);
$x = $this->sub($y1, $this->mul($this->divQ($b, $a), $x1));
$y = $x1;
return [$gcd, $x, $y];
}
/**
* Performs a bitwise operation on a decimal number.
*
* @param 'and'|'or'|'xor' $operator The operator to use.
* @param string $a The left operand.
* @param string $b The right operand.
*
* @pure
*/
private function bitwise(string $operator, string $a, string $b): string
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$aBin = $this->toBinary($aDig);
$bBin = $this->toBinary($bDig);
$aLen = strlen($aBin);
$bLen = strlen($bBin);
if ($aLen > $bLen) {
$bBin = str_repeat("\x00", $aLen - $bLen) . $bBin;
} elseif ($bLen > $aLen) {
$aBin = str_repeat("\x00", $bLen - $aLen) . $aBin;
}
if ($aNeg) {
$aBin = $this->twosComplement($aBin);
}
if ($bNeg) {
$bBin = $this->twosComplement($bBin);
}
$value = match ($operator) {
'and' => $aBin & $bBin,
'or' => $aBin | $bBin,
'xor' => $aBin ^ $bBin,
};
$negative = match ($operator) {
'and' => $aNeg and $bNeg,
'or' => $aNeg or $bNeg,
'xor' => $aNeg xor $bNeg,
};
if ($negative) {
$value = $this->twosComplement($value);
}
$result = $this->toDecimal($value);
return $negative ? $this->neg($result) : $result;
}
/**
* @param string $number A positive, binary number.
*
* @pure
*/
private function twosComplement(string $number): string
{
$xor = str_repeat("\xff", strlen($number));
$number ^= $xor;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$byte = ord($number[$i]);
if (++$byte !== 256) {
$number[$i] = chr($byte);
break;
}
$number[$i] = "\x00";
if ($i === 0) {
$number = "\x01" . $number;
}
}
return $number;
}
/**
* Converts a decimal number to a binary string.
*
* @param string $number The number to convert, positive or zero, only digits.
*
* @pure
*/
private function toBinary(string $number): string
{
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, '256');
$result .= chr((int) $remainder);
}
return strrev($result);
}
/**
* Returns the positive decimal representation of a binary number.
*
* @param string $bytes The bytes representing the number.
*
* @pure
*/
private function toDecimal(string $bytes): string
{
$result = '0';
$power = '1';
for ($i = strlen($bytes) - 1; $i >= 0; $i--) {
$index = ord($bytes[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, '256');
}
}
return $result;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function bcadd;
use function bcdiv;
use function bcmod;
use function bcmul;
use function bcpow;
use function bcpowmod;
use function bcsqrt;
use function bcsub;
/**
* Calculator implementation built around the bcmath library.
*
* @internal
*/
final readonly class BcMathCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return bcadd($a, $b, 0);
}
#[Override]
public function sub(string $a, string $b): string
{
return bcsub($a, $b, 0);
}
#[Override]
public function mul(string $a, string $b): string
{
return bcmul($a, $b, 0);
}
#[Override]
public function divQ(string $a, string $b): string
{
return bcdiv($a, $b, 0);
}
#[Override]
public function divR(string $a, string $b): string
{
return bcmod($a, $b, 0);
}
#[Override]
public function divQR(string $a, string $b): array
{
$q = bcdiv($a, $b, 0);
$r = bcmod($a, $b, 0);
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
return bcpow($a, (string) $e, 0);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
return bcpowmod($base, $exp, $mod, 0);
}
#[Override]
public function sqrt(string $n): string
{
return bcsqrt($n, 0);
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use GMP;
use Override;
use function gmp_add;
use function gmp_and;
use function gmp_div_q;
use function gmp_div_qr;
use function gmp_div_r;
use function gmp_gcd;
use function gmp_init;
use function gmp_invert;
use function gmp_lcm;
use function gmp_mul;
use function gmp_or;
use function gmp_pow;
use function gmp_powm;
use function gmp_sqrt;
use function gmp_strval;
use function gmp_sub;
use function gmp_xor;
/**
* Calculator implementation built around the GMP library.
*
* @internal
*/
final readonly class GmpCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return gmp_strval(gmp_add($a, $b));
}
#[Override]
public function sub(string $a, string $b): string
{
return gmp_strval(gmp_sub($a, $b));
}
#[Override]
public function mul(string $a, string $b): string
{
return gmp_strval(gmp_mul($a, $b));
}
#[Override]
public function divQ(string $a, string $b): string
{
return gmp_strval(gmp_div_q($a, $b));
}
#[Override]
public function divR(string $a, string $b): string
{
return gmp_strval(gmp_div_r($a, $b));
}
#[Override]
public function divQR(string $a, string $b): array
{
[$q, $r] = gmp_div_qr($a, $b);
/**
* @var GMP $q
* @var GMP $r
*/
return [
gmp_strval($q),
gmp_strval($r),
];
}
#[Override]
public function pow(string $a, int $e): string
{
return gmp_strval(gmp_pow($a, $e));
}
#[Override]
public function modInverse(string $x, string $m): ?string
{
$result = gmp_invert($x, $m);
if ($result === false) {
return null;
}
return gmp_strval($result);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
return gmp_strval(gmp_powm($base, $exp, $mod));
}
#[Override]
public function gcd(string $a, string $b): string
{
return gmp_strval(gmp_gcd($a, $b));
}
#[Override]
public function lcm(string $a, string $b): string
{
return gmp_strval(gmp_lcm($a, $b));
}
#[Override]
public function fromBase(string $number, int $base): string
{
return gmp_strval(gmp_init($number, $base));
}
#[Override]
public function toBase(string $number, int $base): string
{
return gmp_strval($number, $base);
}
#[Override]
public function and(string $a, string $b): string
{
return gmp_strval(gmp_and($a, $b));
}
#[Override]
public function or(string $a, string $b): string
{
return gmp_strval(gmp_or($a, $b));
}
#[Override]
public function xor(string $a, string $b): string
{
return gmp_strval(gmp_xor($a, $b));
}
#[Override]
public function sqrt(string $n): string
{
return gmp_strval(gmp_sqrt($n));
}
}
@@ -0,0 +1,616 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function assert;
use function in_array;
use function intdiv;
use function is_int;
use function ltrim;
use function str_pad;
use function str_repeat;
use function strcmp;
use function strlen;
use function substr;
use const PHP_INT_SIZE;
use const STR_PAD_LEFT;
/**
* Calculator implementation using only native PHP code.
*
* @internal
*/
final readonly class NativeCalculator extends Calculator
{
/**
* The max number of digits the platform can natively add, subtract, multiply or divide without overflow.
* For multiplication, this represents the max sum of the lengths of both operands.
*
* In addition, it is assumed that an extra digit can hold a carry (1) without overflowing.
* Example: 32-bit: max number 1,999,999,999 (9 digits + carry)
* 64-bit: max number 1,999,999,999,999,999,999 (18 digits + carry)
*/
private int $maxDigits;
/**
* @pure
*
* @codeCoverageIgnore
*/
public function __construct()
{
$this->maxDigits = match (PHP_INT_SIZE) {
4 => 9,
8 => 18,
};
}
#[Override]
public function add(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a + $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0') {
return $b;
}
if ($b === '0') {
return $a;
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $aNeg === $bNeg ? $this->doAdd($aDig, $bDig) : $this->doSub($aDig, $bDig);
if ($aNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function sub(string $a, string $b): string
{
return $this->add($a, $this->neg($b));
}
#[Override]
public function mul(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a * $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0' || $b === '0') {
return '0';
}
if ($a === '1') {
return $b;
}
if ($b === '1') {
return $a;
}
if ($a === '-1') {
return $this->neg($b);
}
if ($b === '-1') {
return $this->neg($a);
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $this->doMul($aDig, $bDig);
if ($aNeg !== $bNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function divQ(string $a, string $b): string
{
return $this->divQR($a, $b)[0];
}
#[Override]
public function divR(string $a, string $b): string
{
return $this->divQR($a, $b)[1];
}
#[Override]
public function divQR(string $a, string $b): array
{
if ($a === '0') {
return ['0', '0'];
}
if ($a === $b) {
return ['1', '0'];
}
if ($b === '1') {
return [$a, '0'];
}
if ($b === '-1') {
return [$this->neg($a), '0'];
}
/** @var numeric-string $a */
$na = $a * 1; // cast to number
if (is_int($na)) {
/** @var numeric-string $b */
$nb = $b * 1;
if (is_int($nb)) {
// the only division that may overflow is PHP_INT_MIN / -1,
// which cannot happen here as we've already handled a divisor of -1 above.
$q = intdiv($na, $nb);
$r = $na % $nb;
return [
(string) $q,
(string) $r,
];
}
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
[$q, $r] = $this->doDiv($aDig, $bDig);
if ($aNeg !== $bNeg) {
$q = $this->neg($q);
}
if ($aNeg) {
$r = $this->neg($r);
}
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
if ($e === 0) {
return '1';
}
if ($e === 1) {
return $a;
}
$odd = $e % 2;
$e -= $odd;
$aa = $this->mul($a, $a);
$result = $this->pow($aa, $e / 2);
if ($odd === 1) {
$result = $this->mul($result, $a);
}
return $result;
}
/**
* Algorithm from: https://www.geeksforgeeks.org/modular-exponentiation-power-in-modular-arithmetic/.
*/
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
// special case: the algorithm below fails with power 0 mod 1 (returns 1 instead of 0)
if ($exp === '0' && $mod === '1') {
return '0';
}
$x = $base;
$res = '1';
// numbers are positive, so we can use remainder instead of modulo
$x = $this->divR($x, $mod);
while ($exp !== '0') {
if (in_array($exp[-1], ['1', '3', '5', '7', '9'])) { // odd
$res = $this->divR($this->mul($res, $x), $mod);
}
$exp = $this->divQ($exp, '2');
$x = $this->divR($this->mul($x, $x), $mod);
}
return $res;
}
/**
* Adapted from https://cp-algorithms.com/num_methods/roots_newton.html.
*/
#[Override]
public function sqrt(string $n): string
{
if ($n === '0') {
return '0';
}
// initial approximation
$x = str_repeat('9', intdiv(strlen($n), 2) ?: 1);
$decreased = false;
for (; ;) {
$nx = $this->divQ($this->add($x, $this->divQ($n, $x)), '2');
if ($x === $nx || $this->cmp($nx, $x) > 0 && $decreased) {
break;
}
$decreased = $this->cmp($nx, $x) < 0;
$x = $nx;
}
return $x;
}
/**
* Performs the addition of two non-signed large integers.
*
* @pure
*/
private function doAdd(string $a, string $b): string
{
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = (string) ($blockA + $blockB + $carry);
$sumLength = strlen($sum);
if ($sumLength > $blockLength) {
$sum = substr($sum, 1);
$carry = 1;
} else {
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$carry = 0;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
if ($carry === 1) {
$result = '1' . $result;
}
return $result;
}
/**
* Performs the subtraction of two non-signed large integers.
*
* @pure
*/
private function doSub(string $a, string $b): string
{
if ($a === $b) {
return '0';
}
// Ensure that we always subtract to a positive result: biggest minus smallest.
$cmp = $this->doCmp($a, $b);
$invert = ($cmp === -1);
if ($invert) {
$c = $a;
$a = $b;
$b = $c;
}
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
$complement = 10 ** $this->maxDigits;
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = $blockA - $blockB - $carry;
if ($sum < 0) {
$sum += $complement;
$carry = 1;
} else {
$carry = 0;
}
$sum = (string) $sum;
$sumLength = strlen($sum);
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
// Carry cannot be 1 when the loop ends, as a > b
assert($carry === 0);
$result = ltrim($result, '0');
if ($invert) {
$result = $this->neg($result);
}
return $result;
}
/**
* Performs the multiplication of two non-signed large integers.
*
* @pure
*/
private function doMul(string $a, string $b): string
{
$x = strlen($a);
$y = strlen($b);
$maxDigits = intdiv($this->maxDigits, 2);
$complement = 10 ** $maxDigits;
$result = '0';
for ($i = $x - $maxDigits; ; $i -= $maxDigits) {
$blockALength = $maxDigits;
if ($i < 0) {
$blockALength += $i;
$i = 0;
}
$blockA = (int) substr($a, $i, $blockALength);
$line = '';
$carry = 0;
for ($j = $y - $maxDigits; ; $j -= $maxDigits) {
$blockBLength = $maxDigits;
if ($j < 0) {
$blockBLength += $j;
$j = 0;
}
$blockB = (int) substr($b, $j, $blockBLength);
$mul = $blockA * $blockB + $carry;
$value = $mul % $complement;
$carry = ($mul - $value) / $complement;
$value = (string) $value;
$value = str_pad($value, $maxDigits, '0', STR_PAD_LEFT);
$line = $value . $line;
if ($j === 0) {
break;
}
}
if ($carry !== 0) {
$line = $carry . $line;
}
$line = ltrim($line, '0');
if ($line !== '') {
$line .= str_repeat('0', $x - $blockALength - $i);
$result = $this->add($result, $line);
}
if ($i === 0) {
break;
}
}
return $result;
}
/**
* Performs the division of two non-signed large integers.
*
* @return string[] The quotient and remainder.
*
* @pure
*/
private function doDiv(string $a, string $b): array
{
$cmp = $this->doCmp($a, $b);
if ($cmp === -1) {
return ['0', $a];
}
$x = strlen($a);
$y = strlen($b);
// we now know that a >= b && x >= y
$q = '0'; // quotient
$r = $a; // remainder
$z = $y; // focus length, always $y or $y+1
/** @var numeric-string $b */
$nb = $b * 1; // cast to number
// performance optimization in cases where the remainder will never cause int overflow
if (is_int(($nb - 1) * 10 + 9)) {
$r = (int) substr($a, 0, $z - 1);
for ($i = $z - 1; $i < $x; $i++) {
$n = $r * 10 + (int) $a[$i];
/** @var int $nb */
$q .= intdiv($n, $nb);
$r = $n % $nb;
}
return [ltrim($q, '0') ?: '0', (string) $r];
}
for (; ;) {
$focus = substr($a, 0, $z);
$cmp = $this->doCmp($focus, $b);
if ($cmp === -1) {
if ($z === $x) { // remainder < dividend
break;
}
$z++;
}
$zeros = str_repeat('0', $x - $z);
$q = $this->add($q, '1' . $zeros);
$a = $this->sub($a, $b . $zeros);
$r = $a;
if ($r === '0') { // remainder == 0
break;
}
$x = strlen($a);
if ($x < $y) { // remainder < dividend
break;
}
$z = $y;
}
return [$q, $r];
}
/**
* Compares two non-signed large numbers.
*
* @return -1|0|1
*
* @pure
*/
private function doCmp(string $a, string $b): int
{
$x = strlen($a);
$y = strlen($b);
$cmp = $x <=> $y;
if ($cmp !== 0) {
return $cmp;
}
return strcmp($a, $b) <=> 0; // enforce -1|0|1
}
/**
* Pads the left of one of the given numbers with zeros if necessary to make both numbers the same length.
*
* The numbers must only consist of digits, without leading minus sign.
*
* @return array{string, string, int}
*
* @pure
*/
private function pad(string $a, string $b): array
{
$x = strlen($a);
$y = strlen($b);
if ($x > $y) {
$b = str_repeat('0', $x - $y) . $b;
return [$a, $b, $x];
}
if ($x < $y) {
$a = str_repeat('0', $y - $x) . $a;
return [$a, $b, $y];
}
return [$a, $b, $x];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use function extension_loaded;
/**
* Stores the current Calculator instance used by BigNumber classes.
*
* @internal
*/
final class CalculatorRegistry
{
/**
* The Calculator instance in use.
*/
private static ?Calculator $instance = null;
/**
* Sets the Calculator instance to use.
*
* An instance is typically set only in unit tests: autodetect is usually the best option.
*
* @param Calculator|null $calculator The calculator instance, or null to revert to autodetect.
*/
final public static function set(?Calculator $calculator): void
{
self::$instance = $calculator;
}
/**
* Returns the Calculator instance to use.
*
* If none has been explicitly set, the fastest available implementation will be returned.
*
* Note: even though this method is not technically pure, it is considered pure when used in a normal context, when
* only relying on autodetect.
*
* @pure
*/
final public static function get(): Calculator
{
/** @phpstan-ignore impure.staticPropertyAccess */
if (self::$instance === null) {
/** @phpstan-ignore impure.propertyAssign */
self::$instance = self::detect();
}
/** @phpstan-ignore impure.staticPropertyAccess */
return self::$instance;
}
/**
* Returns the fastest available Calculator implementation.
*
* @pure
*
* @codeCoverageIgnore
*/
private static function detect(): Calculator
{
if (extension_loaded('gmp')) {
return new Calculator\GmpCalculator();
}
if (extension_loaded('bcmath')) {
return new Calculator\BcMathCalculator();
}
return new Calculator\NativeCalculator();
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
/**
* Specifies rounding behavior by defining how discarded digits affect the returned result when an exact value cannot
* be represented at the requested scale.
*/
enum RoundingMode
{
/**
* Asserts that the requested operation has an exact result, hence no rounding is necessary.
*
* If this rounding mode is specified on an operation that yields a result that
* cannot be represented at the requested scale, a RoundingNecessaryException is thrown.
*/
case Unnecessary;
/**
* Rounds away from zero.
*
* Always increments the digit prior to a nonzero discarded fraction.
* Note that this rounding mode never decreases the magnitude of the calculated value.
*/
case Up;
/**
* Rounds towards zero.
*
* Never increments the digit prior to a discarded fraction (i.e., truncates).
* Note that this rounding mode never increases the magnitude of the calculated value.
*/
case Down;
/**
* Rounds towards positive infinity.
*
* If the result is positive, behaves as for Up; if negative, behaves as for Down.
* Note that this rounding mode never decreases the calculated value.
*/
case Ceiling;
/**
* Rounds towards negative infinity.
*
* If the result is positive, behaves as for Down; if negative, behaves as for Up.
* Note that this rounding mode never increases the calculated value.
*/
case Floor;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round up.
*
* Behaves as for Up if the discarded fraction is >= 0.5; otherwise, behaves as for Down.
* Note that this is the rounding mode commonly taught at school.
*/
case HalfUp;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round down.
*
* Behaves as for Up if the discarded fraction is > 0.5; otherwise, behaves as for Down.
*/
case HalfDown;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards positive infinity.
*
* If the result is positive, behaves as for HalfUp; if negative, behaves as for HalfDown.
*/
case HalfCeiling;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards negative infinity.
*
* If the result is positive, behaves as for HalfDown; if negative, behaves as for HalfUp.
*/
case HalfFloor;
/**
* Rounds towards the "nearest neighbor" unless both neighbors are equidistant, in which case rounds towards the even neighbor.
*
* Behaves as for HalfUp if the digit to the left of the discarded fraction is odd;
* behaves as for HalfDown if it's even.
*
* Note that this is the rounding mode that statistically minimizes
* cumulative error when applied repeatedly over a sequence of calculations.
* It is sometimes known as "Banker's rounding", and is chiefly used in the USA.
*/
case HalfEven;
/**
* @deprecated Use RoundingMode::Unnecessary instead.
*/
public const UNNECESSARY = self::Unnecessary;
/**
* @deprecated Use RoundingMode::Up instead.
*/
public const UP = self::Up;
/**
* @deprecated Use RoundingMode::Down instead.
*/
public const DOWN = self::Down;
/**
* @deprecated Use RoundingMode::Ceiling instead.
*/
public const CEILING = self::Ceiling;
/**
* @deprecated Use RoundingMode::Floor instead.
*/
public const FLOOR = self::Floor;
/**
* @deprecated Use RoundingMode::HalfUp instead.
*/
public const HALF_UP = self::HalfUp;
/**
* @deprecated Use RoundingMode::HalfDown instead.
*/
public const HALF_DOWN = self::HalfDown;
/**
* @deprecated Use RoundingMode::HalfCeiling instead.
*/
public const HALF_CEILING = self::HalfCeiling;
/**
* @deprecated Use RoundingMode::HalfFloor instead.
*/
public const HALF_FLOOR = self::HalfFloor;
/**
* @deprecated Use RoundingMode::HalfEven instead.
*/
public const HALF_EVEN = self::HalfEven;
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Carbon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
# carbonphp/carbon-doctrine-types
Types to use Carbon in Doctrine
## Documentation
[Check how to use in the official Carbon documentation](https://carbon.nesbot.com/symfony/)
This package is an externalization of [src/Carbon/Doctrine](https://github.com/briannesbitt/Carbon/tree/2.71.0/src/Carbon/Doctrine)
from `nestbot/carbon` package.
Externalization allows to better deal with different versions of dbal. With
version 4.0 of dbal, it no longer sustainable to be compatible with all version
using a single code.
+36
View File
@@ -0,0 +1,36 @@
{
"name": "carbonphp/carbon-doctrine-types",
"description": "Types to use Carbon in Doctrine",
"type": "library",
"keywords": [
"date",
"time",
"DateTime",
"Carbon",
"Doctrine"
],
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/dbal": "^4.0.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"conflict": {
"doctrine/dbal": "<4.0.0 || >=5.0.0"
},
"license": "MIT",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"authors": [
{
"name": "KyleKatarn",
"email": "kylekatarnls@gmail.com"
}
],
"minimum-stability": "dev"
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform;
interface CarbonDoctrineType
{
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform);
public function convertToPHPValue(mixed $value, AbstractPlatform $platform);
public function convertToDatabaseValue($value, AbstractPlatform $platform);
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonImmutableType extends DateTimeImmutableType implements CarbonDoctrineType
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonType extends DateTimeType implements CarbonDoctrineType
{
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\DB2Platform;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\ValueNotConvertible;
use Exception;
/**
* @template T of CarbonInterface
*/
trait CarbonTypeConverter
{
/**
* This property differentiates types installed by carbonphp/carbon-doctrine-types
* from the ones embedded previously in nesbot/carbon source directly.
*
* @readonly
*/
public bool $external = true;
/**
* @return class-string<T>
*/
protected function getCarbonClassName(): string
{
return Carbon::class;
}
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
{
$precision = min(
$fieldDeclaration['precision'] ?? DateTimeDefaultPrecision::get(),
$this->getMaximumPrecision($platform),
);
$type = parent::getSQLDeclaration($fieldDeclaration, $platform);
if (!$precision) {
return $type;
}
if (str_contains($type, '(')) {
return preg_replace('/\(\d+\)/', "($precision)", $type);
}
[$before, $after] = explode(' ', "$type ");
return trim("$before($precision) $after");
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format('Y-m-d H:i:s.u');
}
throw InvalidType::new(
$value,
static::class,
['null', 'DateTime', 'Carbon']
);
}
private function doConvertToPHPValue(mixed $value)
{
$class = $this->getCarbonClassName();
if ($value === null || is_a($value, $class)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $class::instance($value);
}
$date = null;
$error = null;
try {
$date = $class::parse($value);
} catch (Exception $exception) {
$error = $exception;
}
if (!$date) {
throw ValueNotConvertible::new(
$value,
static::class,
'Y-m-d H:i:s.u or any format supported by '.$class.'::parse()',
$error
);
}
return $date;
}
private function getMaximumPrecision(AbstractPlatform $platform): int
{
if ($platform instanceof DB2Platform) {
return 12;
}
if ($platform instanceof OraclePlatform) {
return 9;
}
if ($platform instanceof SQLServerPlatform || $platform instanceof SQLitePlatform) {
return 3;
}
return 6;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class DateTimeDefaultPrecision
{
private static $precision = 6;
/**
* Change the default Doctrine datetime and datetime_immutable precision.
*
* @param int $precision
*/
public static function set(int $precision): void
{
self::$precision = $precision;
}
/**
* Get the default Doctrine datetime and datetime_immutable precision.
*
* @return int
*/
public static function get(): int
{
return self::$precision;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\VarDateTimeImmutableType;
class DateTimeImmutableType extends VarDateTimeImmutableType implements CarbonDoctrineType
{
/** @use CarbonTypeConverter<CarbonImmutable> */
use CarbonTypeConverter;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CarbonImmutable
{
return $this->doConvertToPHPValue($value);
}
/**
* @return class-string<CarbonImmutable>
*/
protected function getCarbonClassName(): string
{
return CarbonImmutable::class;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\Carbon;
use DateTime;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\VarDateTimeType;
class DateTimeType extends VarDateTimeType implements CarbonDoctrineType
{
/** @use CarbonTypeConverter<Carbon> */
use CarbonTypeConverter;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Carbon
{
return $this->doConvertToPHPValue($value);
}
}
+579
View File
@@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}
+380
View File
@@ -0,0 +1,380 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $required;
$copiedLocalDir = true;
}
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
<?php
// autoload_files.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
'8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php',
'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php',
'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php',
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
'def43f6c87e4f8dfd0c9e1b1bab14fe8' => $vendorDir . '/symfony/polyfill-iconv/bootstrap.php',
'667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php',
'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
'2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php',
'606a39d89246991a373564698c2d8383' => $vendorDir . '/symfony/polyfill-php85/bootstrap.php',
'a1105708a18b76903365ca1c4aa61b02' => $vendorDir . '/symfony/translation/Resources/functions.php',
'35a6ad97d21e794e7e22a17d806652e4' => $vendorDir . '/nunomaduro/termwind/src/Functions.php',
'b33e3d135e5d9e47d845c576147bda89' => $vendorDir . '/php-di/php-di/src/functions.php',
'09f6b20656683369174dd6fa83b7e5fb' => $vendorDir . '/symfony/polyfill-uuid/bootstrap.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
'23dd7ece5822da3d0100ef3deb0ef55f' => $vendorDir . '/laravel/agent-detector/src/functions.php',
'47e1160838b5e5a10346ac4084b58c23' => $vendorDir . '/laravel/prompts/src/helpers.php',
'6124b4c8570aa390c21fafd04a26c69f' => $vendorDir . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php',
'801c31d8ed748cfa537fa45402288c95' => $vendorDir . '/psy/psysh/src/functions.php',
'e39a8b23c42d4e1452234d762b03835a' => $vendorDir . '/ramsey/uuid/src/functions.php',
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'2c2415ec15363ede1bff13a287462ba1' => $vendorDir . '/symfony/polyfill-php86/bootstrap.php',
'476ca15b8d69b04665cd879be9cb4c68' => $vendorDir . '/laravel/framework/src/Illuminate/Collections/functions.php',
'265b4faa2b3a9766332744949e83bf97' => $vendorDir . '/laravel/framework/src/Illuminate/Collections/helpers.php',
'c7a3c339e7e14b60e06a2d7fcce9476b' => $vendorDir . '/laravel/framework/src/Illuminate/Events/functions.php',
'f57d353b41eb2e234b26064d63d8c5dd' => $vendorDir . '/laravel/framework/src/Illuminate/Filesystem/functions.php',
'f0906e6318348a765ffb6eb24e0d0938' => $vendorDir . '/laravel/framework/src/Illuminate/Foundation/helpers.php',
'7f7ac2ddea9cc3fb4b2cc201d63dbc10' => $vendorDir . '/laravel/framework/src/Illuminate/Log/functions.php',
'91892b814db86b8442ad76273bb7aec5' => $vendorDir . '/laravel/framework/src/Illuminate/Reflection/helpers.php',
'493c6aea52f6009bab023b26c21a386a' => $vendorDir . '/laravel/framework/src/Illuminate/Support/functions.php',
'58571171fd5812e6e447dce228f52f4d' => $vendorDir . '/laravel/framework/src/Illuminate/Support/helpers.php',
'309cd39bb536ff667b25a3a76938cb83' => $vendorDir . '/laravel/pao/src/Autoload.php',
'c72349b1fe8d0deeedd3a52e8aa814d8' => $vendorDir . '/mockery/mockery/library/helpers.php',
'ce9671a430e4846b44e1c68c7611f9f5' => $vendorDir . '/mockery/mockery/library/Mockery.php',
'a1cfe24d14977df6878b9bf804af2d1c' => $vendorDir . '/nunomaduro/collision/src/Adapters/Phpunit/Autoload.php',
'ec07570ca5a812141189b1fa81503674' => $vendorDir . '/phpunit/phpunit/src/Framework/Assert/Functions.php',
'377b22b161c09ed6e5152de788ca020a' => $vendorDir . '/spatie/laravel-permission/src/helpers.php',
);
+9
View File
@@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);
+104
View File
@@ -0,0 +1,104 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'voku\\' => array($vendorDir . '/voku/portable-ascii/src/voku'),
'ZBateson\\StreamDecorators\\' => array($vendorDir . '/zbateson/stream-decorators/src'),
'ZBateson\\MbWrapper\\' => array($vendorDir . '/zbateson/mb-wrapper/src'),
'ZBateson\\MailMimeParser\\' => array($vendorDir . '/zbateson/mail-mime-parser/src'),
'Whoops\\' => array($vendorDir . '/filp/whoops/src/Whoops'),
'Webklex\\PHPIMAP\\' => array($vendorDir . '/webklex/php-imap/src'),
'TijsVerkoyen\\CssToInlineStyles\\' => array($vendorDir . '/tijsverkoyen/css-to-inline-styles/src'),
'Tests\\' => array($baseDir . '/tests'),
'Termwind\\' => array($vendorDir . '/nunomaduro/termwind/src'),
'Symfony\\Polyfill\\Uuid\\' => array($vendorDir . '/symfony/polyfill-uuid'),
'Symfony\\Polyfill\\Php86\\' => array($vendorDir . '/symfony/polyfill-php86'),
'Symfony\\Polyfill\\Php85\\' => array($vendorDir . '/symfony/polyfill-php85'),
'Symfony\\Polyfill\\Php84\\' => array($vendorDir . '/symfony/polyfill-php84'),
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
'Symfony\\Polyfill\\Intl\\Normalizer\\' => array($vendorDir . '/symfony/polyfill-intl-normalizer'),
'Symfony\\Polyfill\\Intl\\Idn\\' => array($vendorDir . '/symfony/polyfill-intl-idn'),
'Symfony\\Polyfill\\Intl\\Grapheme\\' => array($vendorDir . '/symfony/polyfill-intl-grapheme'),
'Symfony\\Polyfill\\Iconv\\' => array($vendorDir . '/symfony/polyfill-iconv'),
'Symfony\\Polyfill\\Ctype\\' => array($vendorDir . '/symfony/polyfill-ctype'),
'Symfony\\Contracts\\Translation\\' => array($vendorDir . '/symfony/translation-contracts'),
'Symfony\\Contracts\\Service\\' => array($vendorDir . '/symfony/service-contracts'),
'Symfony\\Contracts\\EventDispatcher\\' => array($vendorDir . '/symfony/event-dispatcher-contracts'),
'Symfony\\Component\\VarDumper\\' => array($vendorDir . '/symfony/var-dumper'),
'Symfony\\Component\\Uid\\' => array($vendorDir . '/symfony/uid'),
'Symfony\\Component\\Translation\\' => array($vendorDir . '/symfony/translation'),
'Symfony\\Component\\String\\' => array($vendorDir . '/symfony/string'),
'Symfony\\Component\\Routing\\' => array($vendorDir . '/symfony/routing'),
'Symfony\\Component\\Process\\' => array($vendorDir . '/symfony/process'),
'Symfony\\Component\\Mime\\' => array($vendorDir . '/symfony/mime'),
'Symfony\\Component\\Mailer\\' => array($vendorDir . '/symfony/mailer'),
'Symfony\\Component\\HttpKernel\\' => array($vendorDir . '/symfony/http-kernel'),
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
'Symfony\\Component\\Finder\\' => array($vendorDir . '/symfony/finder'),
'Symfony\\Component\\EventDispatcher\\' => array($vendorDir . '/symfony/event-dispatcher'),
'Symfony\\Component\\ErrorHandler\\' => array($vendorDir . '/symfony/error-handler'),
'Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'),
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
'Spatie\\Permission\\' => array($vendorDir . '/spatie/laravel-permission/src'),
'Spatie\\LaravelPackageTools\\' => array($vendorDir . '/spatie/laravel-package-tools/src'),
'Ramsey\\Uuid\\' => array($vendorDir . '/ramsey/uuid/src'),
'Ramsey\\Collection\\' => array($vendorDir . '/ramsey/collection/src'),
'Psy\\' => array($vendorDir . '/psy/psysh/src'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
'Psr\\EventDispatcher\\' => array($vendorDir . '/psr/event-dispatcher/src'),
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
'Psr\\Clock\\' => array($vendorDir . '/psr/clock/src'),
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),
'PhpOption\\' => array($vendorDir . '/phpoption/phpoption/src/PhpOption'),
'NunoMaduro\\Collision\\' => array($vendorDir . '/nunomaduro/collision/src'),
'Nette\\' => array($vendorDir . '/nette/schema/src', $vendorDir . '/nette/utils/src'),
'Monolog\\' => array($vendorDir . '/monolog/monolog/src/Monolog'),
'Mockery\\' => array($vendorDir . '/mockery/mockery/library/Mockery'),
'League\\Uri\\' => array($vendorDir . '/league/uri', $vendorDir . '/league/uri-interfaces'),
'League\\MimeTypeDetection\\' => array($vendorDir . '/league/mime-type-detection/src'),
'League\\Flysystem\\Local\\' => array($vendorDir . '/league/flysystem-local'),
'League\\Flysystem\\' => array($vendorDir . '/league/flysystem/src'),
'League\\Config\\' => array($vendorDir . '/league/config/src'),
'League\\CommonMark\\' => array($vendorDir . '/league/commonmark/src'),
'Laravel\\Tinker\\' => array($vendorDir . '/laravel/tinker/src'),
'Laravel\\SerializableClosure\\' => array($vendorDir . '/laravel/serializable-closure/src'),
'Laravel\\Prompts\\' => array($vendorDir . '/laravel/prompts/src'),
'Laravel\\Pao\\' => array($vendorDir . '/laravel/pao/src'),
'Laravel\\Pail\\' => array($vendorDir . '/laravel/pail/src'),
'Laravel\\AgentDetector\\' => array($vendorDir . '/laravel/agent-detector/src'),
'Invoker\\' => array($vendorDir . '/php-di/invoker/src'),
'Illuminate\\Support\\' => array($vendorDir . '/laravel/framework/src/Illuminate/Macroable', $vendorDir . '/laravel/framework/src/Illuminate/Collections', $vendorDir . '/laravel/framework/src/Illuminate/Conditionable', $vendorDir . '/laravel/framework/src/Illuminate/Reflection'),
'Illuminate\\' => array($vendorDir . '/laravel/framework/src/Illuminate'),
'GuzzleHttp\\UriTemplate\\' => array($vendorDir . '/guzzlehttp/uri-template/src'),
'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'),
'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'),
'GrahamCampbell\\ResultType\\' => array($vendorDir . '/graham-campbell/result-type/src'),
'Fruitcake\\Cors\\' => array($vendorDir . '/fruitcake/php-cors/src'),
'Faker\\' => array($vendorDir . '/fakerphp/faker/src/Faker'),
'Egulias\\EmailValidator\\' => array($vendorDir . '/egulias/email-validator/src'),
'Dotenv\\' => array($vendorDir . '/vlucas/phpdotenv/src'),
'Doctrine\\Inflector\\' => array($vendorDir . '/doctrine/inflector/src'),
'Doctrine\\Common\\Lexer\\' => array($vendorDir . '/doctrine/lexer/src'),
'DirectoryTree\\ImapEngine\\Laravel\\' => array($vendorDir . '/directorytree/imapengine-laravel/src'),
'DirectoryTree\\ImapEngine\\' => array($vendorDir . '/directorytree/imapengine/src'),
'Dflydev\\DotAccessData\\' => array($vendorDir . '/dflydev/dot-access-data/src'),
'DeepCopy\\' => array($vendorDir . '/myclabs/deep-copy/src/DeepCopy'),
'Database\\Seeders\\' => array($baseDir . '/database/seeders', $vendorDir . '/laravel/pint/database/seeders'),
'Database\\Factories\\' => array($baseDir . '/database/factories', $vendorDir . '/laravel/pint/database/factories'),
'DI\\' => array($vendorDir . '/php-di/php-di/src'),
'Cron\\' => array($vendorDir . '/dragonmantank/cron-expression/src/Cron'),
'Carbon\\Doctrine\\' => array($vendorDir . '/carbonphp/carbon-doctrine-types/src/Carbon/Doctrine'),
'Carbon\\' => array($vendorDir . '/nesbot/carbon/src/Carbon'),
'Brick\\Math\\' => array($vendorDir . '/brick/math/src'),
'App\\' => array($baseDir . '/app', $vendorDir . '/laravel/pint/app'),
);
+50
View File
@@ -0,0 +1,50 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit74199c0a3889e8d1256a25f93805ef56
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit74199c0a3889e8d1256a25f93805ef56', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit74199c0a3889e8d1256a25f93805ef56', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit74199c0a3889e8d1256a25f93805ef56::getInitializer($loader));
$loader->register(true);
$filesToLoad = \Composer\Autoload\ComposerStaticInit74199c0a3889e8d1256a25f93805ef56::$files;
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
require $file;
}
}, null, null);
foreach ($filesToLoad as $fileIdentifier => $file) {
$requireFile($fileIdentifier, $file);
}
return $loader;
}
}
File diff suppressed because it is too large Load Diff
+9415
View File
File diff suppressed because it is too large Load Diff
+1418
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 80400)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.4.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
trigger_error(
'Composer detected issues in your platform: ' . implode(' ', $issues),
E_USER_ERROR
);
}
+74
View File
@@ -0,0 +1,74 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [3.0.3] - 2024-07-08
### Fixed
- Fixed PHP 8.4 deprecation notices (#47)
## [3.0.2] - 2022-10-27
### Fixed
- Added missing return types to docblocks (#44, #45)
## [3.0.1] - 2021-08-13
### Added
- Adds ReturnTypeWillChange to suppress PHP 8.1 warnings (#40)
## [3.0.0] - 2021-01-01
### Added
- Added support for both `.` and `/`-delimited key paths (#24)
- Added parameter and return types to everything; enabled strict type checks (#18)
- Added new exception classes to better identify certain types of errors (#20)
- `Data` now implements `ArrayAccess` (#17)
- Added ability to merge non-associative array values (#31, #32)
### Changed
- All thrown exceptions are now instances or subclasses of `DataException` (#20)
- Calling `get()` on a missing key path without providing a default will throw a `MissingPathException` instead of returning `null` (#29)
- Bumped supported PHP versions to 7.1 - 8.x (#18)
### Fixed
- Fixed incorrect merging of array values into string values (#32)
- Fixed `get()` method behaving as if keys with `null` values didn't exist
## [2.0.0] - 2017-12-21
### Changed
- Bumped supported PHP versions to 7.0 - 7.4 (#12)
- Switched to PSR-4 autoloading
## [1.1.0] - 2017-01-20
### Added
- Added new `has()` method to check for the existence of the given key (#4, #7)
## [1.0.1] - 2015-08-12
### Added
- Added new optional `$default` parameter to the `get()` method (#2)
## [1.0.0] - 2012-07-17
**Initial release!**
[Unreleased]: https://github.com/dflydev/dflydev-dot-access-data/compare/v3.0.3...main
[3.0.3]: https://github.com/dflydev/dflydev-dot-access-data/compare/v3.0.2...v3.0.3
[3.0.2]: https://github.com/dflydev/dflydev-dot-access-data/compare/v3.0.1...v3.0.2
[3.0.1]: https://github.com/dflydev/dflydev-dot-access-data/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/dflydev/dflydev-dot-access-data/compare/v2.0.0...v3.0.0
[2.0.0]: https://github.com/dflydev/dflydev-dot-access-data/compare/v1.1.0...v2.0.0
[1.1.0]: https://github.com/dflydev/dflydev-dot-access-data/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/dflydev/dflydev-dot-access-data/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/dflydev/dflydev-dot-access-data/releases/tag/v1.0.0
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2012 Dragonfly Development Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+158
View File
@@ -0,0 +1,158 @@
Dot Access Data
===============
[![Latest Version](https://img.shields.io/packagist/v/dflydev/dot-access-data.svg?style=flat-square)](https://packagist.org/packages/dflydev/dot-access-data)
[![Total Downloads](https://img.shields.io/packagist/dt/dflydev/dot-access-data.svg?style=flat-square)](https://packagist.org/packages/dflydev/dot-access-data)
[![Software License](https://img.shields.io/badge/License-MIT-brightgreen.svg?style=flat-square)](LICENSE)
[![Build Status](https://img.shields.io/github/workflow/status/dflydev/dflydev-dot-access-data/Tests/main.svg?style=flat-square)](https://github.com/dflydev/dflydev-dot-access-data/actions?query=workflow%3ATests+branch%3Amain)
[![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/dflydev/dflydev-dot-access-data.svg?style=flat-square)](https://scrutinizer-ci.com/g/dflydev/dflydev-dot-access-data/code-structure/)
[![Quality Score](https://img.shields.io/scrutinizer/g/dflydev/dflydev-dot-access-data.svg?style=flat-square)](https://scrutinizer-ci.com/g/dflydev/dflydev-dot-access-data)
Given a deep data structure, access data by dot notation.
Requirements
------------
* PHP (7.1+)
> For PHP (5.3+) please refer to version `1.0`.
Usage
-----
Abstract example:
```php
use Dflydev\DotAccessData\Data;
$data = new Data;
$data->set('a.b.c', 'C');
$data->set('a.b.d', 'D1');
$data->append('a.b.d', 'D2');
$data->set('a.b.e', ['E0', 'E1', 'E2']);
// C
$data->get('a.b.c');
// ['D1', 'D2']
$data->get('a.b.d');
// ['E0', 'E1', 'E2']
$data->get('a.b.e');
// true
$data->has('a.b.c');
// false
$data->has('a.b.d.j');
// 'some-default-value'
$data->get('some.path.that.does.not.exist', 'some-default-value');
// throws a MissingPathException because no default was given
$data->get('some.path.that.does.not.exist');
```
A more concrete example:
```php
use Dflydev\DotAccessData\Data;
$data = new Data([
'hosts' => [
'hewey' => [
'username' => 'hman',
'password' => 'HPASS',
'roles' => ['web'],
],
'dewey' => [
'username' => 'dman',
'password' => 'D---S',
'roles' => ['web', 'db'],
'nick' => 'dewey dman',
],
'lewey' => [
'username' => 'lman',
'password' => 'LP@$$',
'roles' => ['db'],
],
],
]);
// hman
$username = $data->get('hosts.hewey.username');
// HPASS
$password = $data->get('hosts.hewey.password');
// ['web']
$roles = $data->get('hosts.hewey.roles');
// dewey dman
$nick = $data->get('hosts.dewey.nick');
// Unknown
$nick = $data->get('hosts.lewey.nick', 'Unknown');
// DataInterface instance
$dewey = $data->getData('hosts.dewey');
// dman
$username = $dewey->get('username');
// D---S
$password = $dewey->get('password');
// ['web', 'db']
$roles = $dewey->get('roles');
// No more lewey
$data->remove('hosts.lewey');
// Add DB to hewey's roles
$data->append('hosts.hewey.roles', 'db');
$data->set('hosts.april', [
'username' => 'aman',
'password' => '@---S',
'roles' => ['web'],
]);
// Check if a key exists (true to this case)
$hasKey = $data->has('hosts.dewey.username');
```
`Data` may be used as an array, since it implements `ArrayAccess` interface:
```php
// Get
$data->get('name') === $data['name']; // true
$data['name'] = 'Dewey';
// is equivalent to
$data->set($name, 'Dewey');
isset($data['name']) === $data->has('name');
// Remove key
unset($data['name']);
```
`/` can also be used as a path delimiter:
```php
$data->set('a/b/c', 'd');
echo $data->get('a/b/c'); // "d"
$data->get('a/b/c') === $data->get('a.b.c'); // true
```
License
-------
This library is licensed under the MIT License - see the LICENSE file
for details.
Community
---------
If you have questions or want to help out, join us in the
[#dflydev](irc://irc.freenode.net/#dflydev) channel on irc.freenode.net.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "dflydev/dot-access-data",
"type": "library",
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": ["dot", "access", "data", "notation"],
"license": "MIT",
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "info@dflydev.com",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "beau@dflydev.com",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "carlos@kiwing.it",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com"
}
],
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Dflydev\\DotAccessData\\": "tests/"
}
},
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"scripts": {
"phpcs": "phpcs",
"phpstan": "phpstan analyse",
"phpunit": "phpunit --no-coverage",
"psalm": "psalm",
"test": [
"@phpcs",
"@phpstan",
"@psalm",
"@phpunit"
]
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData;
use ArrayAccess;
use Dflydev\DotAccessData\Exception\DataException;
use Dflydev\DotAccessData\Exception\InvalidPathException;
use Dflydev\DotAccessData\Exception\MissingPathException;
/**
* @implements ArrayAccess<string, mixed>
*/
class Data implements DataInterface, ArrayAccess
{
private const DELIMITERS = ['.', '/'];
/**
* Internal representation of data data
*
* @var array<string, mixed>
*/
protected $data;
/**
* Constructor
*
* @param array<string, mixed> $data
*/
public function __construct(array $data = [])
{
$this->data = $data;
}
/**
* {@inheritdoc}
*/
public function append(string $key, $value = null): void
{
$currentValue =& $this->data;
$keyPath = self::keyToPathArray($key);
$endKey = array_pop($keyPath);
foreach ($keyPath as $currentKey) {
if (! isset($currentValue[$currentKey])) {
$currentValue[$currentKey] = [];
}
$currentValue =& $currentValue[$currentKey];
}
if (!isset($currentValue[$endKey])) {
$currentValue[$endKey] = [];
}
if (!is_array($currentValue[$endKey])) {
// Promote this key to an array.
// TODO: Is this really what we want to do?
$currentValue[$endKey] = [$currentValue[$endKey]];
}
$currentValue[$endKey][] = $value;
}
/**
* {@inheritdoc}
*/
public function set(string $key, $value = null): void
{
$currentValue =& $this->data;
$keyPath = self::keyToPathArray($key);
$endKey = array_pop($keyPath);
foreach ($keyPath as $currentKey) {
if (!isset($currentValue[$currentKey])) {
$currentValue[$currentKey] = [];
}
if (!is_array($currentValue[$currentKey])) {
throw new DataException(sprintf('Key path "%s" within "%s" cannot be indexed into (is not an array)', $currentKey, self::formatPath($key)));
}
$currentValue =& $currentValue[$currentKey];
}
$currentValue[$endKey] = $value;
}
/**
* {@inheritdoc}
*/
public function remove(string $key): void
{
$currentValue =& $this->data;
$keyPath = self::keyToPathArray($key);
$endKey = array_pop($keyPath);
foreach ($keyPath as $currentKey) {
if (!isset($currentValue[$currentKey])) {
return;
}
$currentValue =& $currentValue[$currentKey];
}
unset($currentValue[$endKey]);
}
/**
* {@inheritdoc}
*
* @psalm-mutation-free
*/
public function get(string $key, $default = null)
{
/** @psalm-suppress ImpureFunctionCall */
$hasDefault = \func_num_args() > 1;
$currentValue = $this->data;
$keyPath = self::keyToPathArray($key);
foreach ($keyPath as $currentKey) {
if (!is_array($currentValue) || !array_key_exists($currentKey, $currentValue)) {
if ($hasDefault) {
return $default;
}
throw new MissingPathException($key, sprintf('No data exists at the given path: "%s"', self::formatPath($keyPath)));
}
$currentValue = $currentValue[$currentKey];
}
return $currentValue === null ? $default : $currentValue;
}
/**
* {@inheritdoc}
*
* @psalm-mutation-free
*/
public function has(string $key): bool
{
$currentValue = $this->data;
foreach (self::keyToPathArray($key) as $currentKey) {
if (
!is_array($currentValue) ||
!array_key_exists($currentKey, $currentValue)
) {
return false;
}
$currentValue = $currentValue[$currentKey];
}
return true;
}
/**
* {@inheritdoc}
*
* @psalm-mutation-free
*/
public function getData(string $key): DataInterface
{
$value = $this->get($key);
if (is_array($value) && Util::isAssoc($value)) {
return new Data($value);
}
throw new DataException(sprintf('Value at "%s" could not be represented as a DataInterface', self::formatPath($key)));
}
/**
* {@inheritdoc}
*/
public function import(array $data, int $mode = self::REPLACE): void
{
$this->data = Util::mergeAssocArray($this->data, $data, $mode);
}
/**
* {@inheritdoc}
*/
public function importData(DataInterface $data, int $mode = self::REPLACE): void
{
$this->import($data->export(), $mode);
}
/**
* {@inheritdoc}
*
* @psalm-mutation-free
*/
public function export(): array
{
return $this->data;
}
/**
* {@inheritdoc}
*
* @return bool
*/
#[\ReturnTypeWillChange]
public function offsetExists($key)
{
return $this->has($key);
}
/**
* {@inheritdoc}
*
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($key)
{
return $this->get($key, null);
}
/**
* {@inheritdoc}
*
* @param string $key
* @param mixed $value
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($key, $value)
{
$this->set($key, $value);
}
/**
* {@inheritdoc}
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($key)
{
$this->remove($key);
}
/**
* @param string $path
*
* @return string[]
*
* @psalm-return non-empty-list<string>
*
* @psalm-pure
*/
protected static function keyToPathArray(string $path): array
{
if (\strlen($path) === 0) {
throw new InvalidPathException('Path cannot be an empty string');
}
$path = \str_replace(self::DELIMITERS, '.', $path);
return \explode('.', $path);
}
/**
* @param string|string[] $path
*
* @return string
*
* @psalm-pure
*/
protected static function formatPath($path): string
{
if (is_string($path)) {
$path = self::keyToPathArray($path);
}
return implode(' » ', $path);
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData;
use Dflydev\DotAccessData\Exception\DataException;
use Dflydev\DotAccessData\Exception\InvalidPathException;
interface DataInterface
{
public const PRESERVE = 0;
public const REPLACE = 1;
public const MERGE = 2;
/**
* Append a value to a key (assumes key refers to an array value)
*
* If the key does not yet exist it will be created.
* If the key references a non-array it's existing contents will be added into a new array before appending the new value.
*
* @param string $key
* @param mixed $value
*
* @throws InvalidPathException if the given key is empty
*/
public function append(string $key, $value = null): void;
/**
* Set a value for a key
*
* If the key does not yet exist it will be created.
*
* @param string $key
* @param mixed $value
*
* @throws InvalidPathException if the given key is empty
* @throws DataException if the given key does not target an array
*/
public function set(string $key, $value = null): void;
/**
* Remove a key
*
* No exception will be thrown if the key does not exist
*
* @param string $key
*
* @throws InvalidPathException if the given key is empty
*/
public function remove(string $key): void;
/**
* Get the raw value for a key
*
* If the key does not exist, an optional default value can be returned instead.
* If no default is provided then an exception will be thrown instead.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*
* @throws InvalidPathException if the given key is empty
* @throws InvalidPathException if the given key does not exist and no default value was given
*
* @psalm-mutation-free
*/
public function get(string $key, $default = null);
/**
* Check if the key exists
*
* @param string $key
*
* @return bool
*
* @throws InvalidPathException if the given key is empty
*
* @psalm-mutation-free
*/
public function has(string $key): bool;
/**
* Get a data instance for a key
*
* @param string $key
*
* @return DataInterface
*
* @throws InvalidPathException if the given key is empty
* @throws DataException if the given key does not reference an array
*
* @psalm-mutation-free
*/
public function getData(string $key): DataInterface;
/**
* Import data into existing data
*
* @param array<string, mixed> $data
* @param self::PRESERVE|self::REPLACE|self::MERGE $mode
*/
public function import(array $data, int $mode = self::REPLACE): void;
/**
* Import data from an external data into existing data
*
* @param DataInterface $data
* @param self::PRESERVE|self::REPLACE|self::MERGE $mode
*/
public function importData(DataInterface $data, int $mode = self::REPLACE): void;
/**
* Export data as raw data
*
* @return array<string, mixed>
*
* @psalm-mutation-free
*/
public function export(): array;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData\Exception;
/**
* Base runtime exception type thrown by this library
*/
class DataException extends \RuntimeException
{
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData\Exception;
/**
* Thrown when trying to access an invalid path in the data array
*/
class InvalidPathException extends DataException
{
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData\Exception;
use Throwable;
/**
* Thrown when trying to access a path that does not exist
*/
class MissingPathException extends DataException
{
/** @var string */
protected $path;
public function __construct(string $path, string $message = '', int $code = 0, ?Throwable $previous = null)
{
$this->path = $path;
parent::__construct($message, $code, $previous);
}
public function getPath(): string
{
return $this->path;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* This file is a part of dflydev/dot-access-data.
*
* (c) Dragonfly Development Inc.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Dflydev\DotAccessData;
class Util
{
/**
* Test if array is an associative array
*
* Note that this function will return true if an array is empty. Meaning
* empty arrays will be treated as if they are associative arrays.
*
* @param array<mixed> $arr
*
* @return bool
*
* @psalm-pure
*/
public static function isAssoc(array $arr): bool
{
return !count($arr) || count(array_filter(array_keys($arr), 'is_string')) == count($arr);
}
/**
* Merge contents from one associtative array to another
*
* @param mixed $to
* @param mixed $from
* @param DataInterface::PRESERVE|DataInterface::REPLACE|DataInterface::MERGE $mode
*
* @return mixed
*
* @psalm-pure
*/
public static function mergeAssocArray($to, $from, int $mode = DataInterface::REPLACE)
{
if ($mode === DataInterface::MERGE && self::isList($to) && self::isList($from)) {
return array_merge($to, $from);
}
if (is_array($from) && is_array($to)) {
foreach ($from as $k => $v) {
if (!isset($to[$k])) {
$to[$k] = $v;
} else {
$to[$k] = self::mergeAssocArray($to[$k], $v, $mode);
}
}
return $to;
}
return $mode === DataInterface::PRESERVE ? $to : $from;
}
/**
* @param mixed $value
*
* @return bool
*
* @psalm-pure
*/
private static function isList($value): bool
{
return is_array($value) && array_values($value) === $value;
}
}
+3
View File
@@ -0,0 +1,3 @@
# Changelog
All notable changes to `ImapEngine-Laravel` will be documented in this file.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Steve Bauman <steven_bauman@outlook.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
<p align="center">
<img src="https://github.com/directorytree/imapengine/blob/master/art/logo.svg" width="300">
</p>
<p align="center">Working with IMAP doesn't need to be hard.</p>
<p align="center">ImapEngine provides a simple API for managing mailboxes -- without the PHP extension.</p>
<p align="center">
<a href="https://github.com/directorytree/imapengine-laravel/actions"><img src="https://img.shields.io/github/actions/workflow/status/directorytree/imapengine-laravel/run-tests.yml?branch=master&style=flat-square"></a>
<a href="https://packagist.org/packages/directorytree/imapengine-laravel"><img src="https://img.shields.io/packagist/dt/directorytree/imapengine-laravel.svg?style=flat-square"></a>
<a href="https://packagist.org/packages/directorytree/imapengine-laravel"><img src="https://img.shields.io/packagist/v/directorytree/imapengine-laravel.svg?style=flat-square"></a>
<a href="https://packagist.org/packages/directorytree/imapengine-laravel"><img src="https://img.shields.io/github/license/directorytree/imapengine-laravel?style=flat-square"/></a>
</p>
<p align="center">
<a href="https://imapengine.com/docs/laravel/introduction">View Documentation</a>
</p>
+66
View File
@@ -0,0 +1,66 @@
{
"name": "directorytree/imapengine-laravel",
"description": "This is my package imapengine-laravel",
"keywords": [
"Steve Bauman",
"laravel",
"imap"
],
"homepage": "https://github.com/directorytree/imapengine-laravel",
"license": "MIT",
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"directorytree/imapengine": "^1.19.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0"
},
"require-dev": {
"laravel/pint": "^1.14",
"nunomaduro/collision": "^7.10|^8.1",
"orchestra/testbench": "^8.22|^9.0|^10.0|^11.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"pestphp/pest-plugin-arch": "^2.0|^3.0|^4.0",
"pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0",
"spatie/laravel-ray": "^1.35"
},
"autoload": {
"psr-4": {
"DirectoryTree\\ImapEngine\\Laravel\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"DirectoryTree\\ImapEngine\\Laravel\\Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": "@composer run prepare",
"prepare": "@php vendor/bin/testbench package:discover --ansi",
"analyse": "vendor/bin/phpstan analyse",
"test": "vendor/bin/pest",
"test-coverage": "vendor/bin/pest --coverage",
"format": "vendor/bin/pint"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"phpstan/extension-installer": true
}
},
"extra": {
"laravel": {
"providers": [
"DirectoryTree\\ImapEngine\\Laravel\\ImapServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
+35
View File
@@ -0,0 +1,35 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Mailboxes
|--------------------------------------------------------------------------
|
| Here you may define all of the IMAP mailboxes your application connects to.
| Each mailbox contains its connection settings such as host, port, and
| credentials. You are free to add as many mailboxes as needed.
|
*/
'mailboxes' => [
'default' => [
'port' => env('IMAP_PORT', 993),
'host' => env('IMAP_HOST'),
'timeout' => env('IMAP_TIMEOUT', 30),
'debug' => env('IMAP_DEBUG', false),
'username' => env('IMAP_USERNAME'),
'password' => env('IMAP_PASSWORD'),
'encryption' => env('IMAP_ENCRYPTION', 'ssl'),
'validate_cert' => env('IMAP_VALIDATE_CERT', true),
'authentication' => env('IMAP_AUTHENTICATION', 'plain'),
'proxy' => [
'socket' => env('IMAP_PROXY_SOCKET'),
'username' => env('IMAP_PROXY_USERNAME'),
'password' => env('IMAP_PROXY_PASSWORD'),
'request_fulluri' => env('IMAP_PROXY_REQUEST_FULLURI', false),
],
],
],
];
@@ -0,0 +1,35 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Commands;
use DirectoryTree\ImapEngine\MessageQueryInterface;
class ConfigureIdleQuery
{
/**
* Constructor.
*/
public function __construct(
protected array $with = []
) {}
/**
* Configure the query.
*/
public function __invoke(MessageQueryInterface $query): MessageQueryInterface
{
if (in_array('flags', $this->with)) {
$query->withFlags();
}
if (in_array('body', $this->with)) {
$query->withBody();
}
if (in_array('headers', $this->with)) {
$query->withHeaders();
}
return $query;
}
}
@@ -0,0 +1,37 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Commands;
use Carbon\CarbonInterface;
use DirectoryTree\ImapEngine\Laravel\Events\MessageReceived;
use DirectoryTree\ImapEngine\MessageInterface;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Event;
class HandleMessageReceived
{
/**
* Constructor.
*/
public function __construct(
protected WatchMailbox $command,
protected int &$attempts = 0,
protected ?CarbonInterface &$lastReceivedAt = null,
) {}
/**
* Handle the message received event.
*/
public function __invoke(MessageInterface $message): void
{
$this->command->info(
"Message received: [{$message->uid()}]"
);
$this->attempts = 0;
$this->lastReceivedAt = Date::now();
Event::dispatch(new MessageReceived($message));
}
}
@@ -0,0 +1,130 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Commands;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\Laravel\Events\MailboxWatchAttemptsExceeded;
use DirectoryTree\ImapEngine\Laravel\Facades\Imap;
use DirectoryTree\ImapEngine\Laravel\Support\LoopInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Symfony\Component\Console\Exception\InvalidOptionException;
class WatchMailbox extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'imap:watch {mailbox} {folder?} {--method=idle} {--with=} {--timeout=30} {--attempts=5} {--debug=false}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Watch a mailbox for new messages.';
/**
* Execute the console command.
*/
public function handle(LoopInterface $loop): void
{
if (! in_array($method = $this->option('method'), ['idle', 'poll'])) {
throw new InvalidOptionException("Invalid method [{$method}]. Valid options are [idle, poll].");
}
$mailbox = Imap::mailbox($name = $this->argument('mailbox'));
$with = explode(',', $this->option('with'));
$this->info("Watching mailbox [$name]...");
$attempts = 0;
$lastReceivedAt = null;
$loop->run(function () use ($mailbox, $name, $with, &$attempts, &$lastReceivedAt) {
try {
$folder = $this->folder($mailbox);
match ($this->option('method')) {
'idle' => $folder->idle(
new HandleMessageReceived($this, $attempts, $lastReceivedAt),
new ConfigureIdleQuery($with),
$this->option('timeout'),
),
'poll' => $folder->poll(
new HandleMessageReceived($this, $attempts, $lastReceivedAt),
new ConfigureIdleQuery($with),
$this->option('timeout'),
),
};
} catch (Exception $e) {
if ($this->isMessageMissing($e)) {
return;
}
if ($this->isDisconnection($e)) {
sleep(2);
return;
}
if ($attempts >= $this->option('attempts')) {
$this->info("Exception: {$e->getMessage()}");
Event::dispatch(
new MailboxWatchAttemptsExceeded($name, $attempts, $e, $lastReceivedAt)
);
throw $e;
}
$attempts++;
}
});
}
/**
* Get the mailbox folder to idle.
*/
protected function folder(MailboxInterface $mailbox): FolderInterface
{
return ($folder = $this->argument('folder'))
? $mailbox->folders()->findOrFail($folder)
: $mailbox->inbox();
}
/**
* Determine if the exception is due to a message missing error.
*/
protected function isMessageMissing(Exception $e): bool
{
return Str::contains($e->getMessage(), [
'no longer exist',
], true);
}
/**
* Determine if the exception is caused by a disconnection.
*/
protected function isDisconnection(Exception $e): bool
{
return Str::contains($e->getMessage(), [
'connection reset by peer',
'temporary system problem',
'failed to fetch content',
'connection failed',
'empty response',
'not connected',
'no response',
'broken pipe',
'unavailable',
], true);
}
}
@@ -0,0 +1,19 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Events;
use Carbon\CarbonInterface;
use Exception;
class MailboxWatchAttemptsExceeded
{
/**
* Constructor.
*/
public function __construct(
public string $mailbox,
public int $attempts,
public Exception $exception,
public ?CarbonInterface $lastReceivedAt = null,
) {}
}
@@ -0,0 +1,15 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Events;
use DirectoryTree\ImapEngine\MessageInterface;
class MessageReceived
{
/**
* Create a new event instance.
*/
public function __construct(
public MessageInterface $message
) {}
}
@@ -0,0 +1,41 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Facades;
use DirectoryTree\ImapEngine\Laravel\ImapManager;
use DirectoryTree\ImapEngine\Testing\FakeMailbox;
use Illuminate\Support\Facades\Facade;
/**
* @method static \DirectoryTree\ImapEngine\MailboxInterface mailbox(string $name)
* @method static \DirectoryTree\ImapEngine\MailboxInterface swap(string $name, \DirectoryTree\ImapEngine\MailboxInterface $mailbox)
*/
class Imap extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
return ImapManager::class;
}
/**
* Fake the given mailbox.
*/
public static function fake(
string $mailbox,
array $config = [],
array $folders = [],
array $capabilities = []
): FakeMailbox {
/** @var \DirectoryTree\ImapEngine\Laravel\ImapManager $manager */
$manager = static::getFacadeRoot();
$fake = new FakeMailbox($config, $folders, $capabilities);
$manager->swap($mailbox, $fake);
return $fake;
}
}
@@ -0,0 +1,82 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel;
use DirectoryTree\ImapEngine\Mailbox;
use DirectoryTree\ImapEngine\MailboxInterface;
use InvalidArgumentException;
class ImapManager
{
/**
* The IMAP configuration.
*/
protected array $config = [];
/**
* The mailbox instances.
*/
protected array $mailboxes = [];
/**
* Constructor.
*/
public function __construct(array $config)
{
$this->config = $config;
}
/**
* Get a mailbox instance.
*/
public function mailbox(string $name): MailboxInterface
{
if (isset($this->mailboxes[$name])) {
return $this->mailboxes[$name];
}
if (! array_key_exists($name, $this->config['mailboxes'] ?? [])) {
throw new InvalidArgumentException(
"Mailbox [{$name}] is not defined. Please check your IMAP configuration."
);
}
return $this->mailboxes[$name] = $this->build($this->config['mailboxes'][$name]);
}
/**
* Register a mailbox instance.
*/
public function register(string $name, array $config): static
{
$this->mailboxes[$name] = $this->build($config);
return $this;
}
/**
* Build an on-demand mailbox instance.
*/
public function build(array $config): MailboxInterface
{
return new Mailbox($config);
}
/**
* Remove a mailbox from the in-memory cache.
*/
public function forget(string $name): static
{
unset($this->mailboxes[$name]);
return $this;
}
/**
* Swap out a mailbox instance with a new one.
*/
public function swap(string $name, MailboxInterface $mailbox): void
{
$this->mailboxes[$name] = $mailbox;
}
}
@@ -0,0 +1,38 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel;
use DirectoryTree\ImapEngine\Laravel\Support\Loop;
use DirectoryTree\ImapEngine\Laravel\Support\LoopInterface;
use Illuminate\Support\ServiceProvider;
class ImapServiceProvider extends ServiceProvider
{
/**
* Register application services.
*/
public function register(): void
{
$this->app->singleton(ImapManager::class, function () {
return new ImapManager(config('imap', []));
});
$this->app->bind(LoopInterface::class, Loop::class);
}
/**
* Bootstrap application services.
*/
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
Commands\WatchMailbox::class,
]);
}
$this->publishes([
__DIR__.'/../config/imap.php' => config_path('imap.php'),
]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Support;
class Loop implements LoopInterface
{
/**
* {@inheritDoc}
*/
public function run(callable $tick): void
{
while (true) {
$tick();
}
}
}
@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Support;
class LoopFake implements LoopInterface
{
/**
* {@inheritDoc}
*/
public function run(callable $tick): void
{
$tick();
}
}
@@ -0,0 +1,11 @@
<?php
namespace DirectoryTree\ImapEngine\Laravel\Support;
interface LoopInterface
{
/**
* Execute the loop.
*/
public function run(callable $tick): void;
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "directorytree/imapengine",
"type": "library",
"description": "A fully-featured IMAP library -- without the PHP extension",
"keywords": [
"imap",
"mail",
"engine"
],
"homepage": "https://github.com/directorytree/imapengine",
"license": "MIT",
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"symfony/mime": ">=6.0",
"nesbot/carbon": ">=2.0",
"illuminate/collections": ">=9.0",
"zbateson/mail-mime-parser": "^3.0",
"egulias/email-validator": "^4.0"
},
"require-dev": {
"spatie/ray": "^1.0",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"autoload": {
"psr-4": {
"DirectoryTree\\ImapEngine\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests"
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Address implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $email,
protected string $name,
) {
$this->name = Str::decodeMimeHeader($this->name);
}
/**
* Get the address's email.
*/
public function email(): string
{
return $this->email;
}
/**
* Get the address's name.
*/
public function name(): string
{
return $this->name;
}
/**
* Get the array representation of the address.
*/
public function toArray(): array
{
return [
'email' => $this->email,
'name' => $this->name,
];
}
/**
* Get the JSON representation of the address.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace DirectoryTree\ImapEngine;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\Mime\MimeTypes;
use ZBateson\MailMimeParser\Message\IMessagePart;
class Attachment implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected ?string $filename,
protected ?string $contentId,
protected string $contentType,
protected ?string $contentDisposition,
protected StreamInterface $contentStream,
) {}
/**
* Get attachments from a parsed message.
*
* @return Attachment[]
*/
public static function parsed(MessageInterface $message): array
{
$attachments = [];
foreach ($message->parse()->getAllAttachmentParts() as $part) {
if (static::isForwardedMessage($part)) {
$attachments = array_merge($attachments, (new FileMessage($part->getContent()))->attachments());
} else {
$attachments[] = new Attachment(
$part->getFilename(),
$part->getContentId(),
$part->getContentType(),
$part->getContentDisposition(),
$part->getBinaryContentStream() ?? Utils::streamFor(''),
);
}
}
return $attachments;
}
/**
* Get attachments from a message's body structure using lazy streams.
*
* @return Attachment[]
*/
public static function lazy(Message $message): array
{
$attachments = [];
foreach ($message->bodyStructure(fetch: true)?->attachments() ?? [] as $part) {
$attachments[] = new Attachment(
$part->filename(),
$part->id(),
$part->contentType(),
$part->disposition()?->type()?->value,
new Support\LazyBodyPartStream($message, $part),
);
}
return $attachments;
}
/**
* Determine if the attachment should be treated as an embedded forwarded message.
*/
protected static function isForwardedMessage(IMessagePart $part): bool
{
return empty($part->getFilename())
&& strtolower((string) $part->getContentType()) === 'message/rfc822'
&& strtolower((string) $part->getContentDisposition()) !== 'attachment';
}
/**
* Get the attachment's filename.
*/
public function filename(): ?string
{
return $this->filename;
}
/**
* Get the attachment's content ID.
*/
public function contentId(): ?string
{
return $this->contentId;
}
/**
* Get the attachment's content type.
*/
public function contentType(): string
{
return $this->contentType;
}
/**
* Get the attachment's content disposition.
*/
public function contentDisposition(): string
{
return $this->contentDisposition;
}
/**
* Get the attachment's contents.
*/
public function contents(): string
{
return $this->contentStream->getContents();
}
/**
* Get the attachment's content stream.
*/
public function contentStream(): StreamInterface
{
return $this->contentStream;
}
/**
* Save the attachment to a file.
*/
public function save(string $path): false|int
{
return file_put_contents($path, $this->contents());
}
/**
* Get the attachment's extension.
*/
public function extension(): ?string
{
if ($ext = pathinfo($this->filename ?? '', PATHINFO_EXTENSION)) {
return $ext;
}
if ($ext = (MimeTypes::getDefault()->getExtensions($this->contentType)[0] ?? null)) {
return $ext;
}
return null;
}
/**
* Get the array representation of the attachment.
*/
public function toArray(): array
{
return [
'filename' => $this->filename,
'content_type' => $this->contentType,
'contents' => $this->contents(),
];
}
/**
* Get the JSON representation of the attachment.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,275 @@
<?php
namespace DirectoryTree\ImapEngine;
use Countable;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
/**
* @implements IteratorAggregate<int, BodyStructurePart|BodyStructureCollection>
*/
class BodyStructureCollection implements Arrayable, Countable, IteratorAggregate, JsonSerializable
{
/**
* Constructor.
*
* @param array<BodyStructurePart|BodyStructureCollection> $parts
*/
public function __construct(
protected string $subtype = 'mixed',
protected array $parameters = [],
protected array $parts = [],
) {}
/**
* Parse a multipart BODYSTRUCTURE ListData into a BodyStructureCollection.
*/
public static function fromListData(ListData $data, ?string $partNumber = null): static
{
$tokens = $data->tokens();
$parts = [];
$childIndex = 1;
$subtypeIndex = null;
foreach ($tokens as $index => $token) {
if ($token instanceof Token && ! $token instanceof Nil) {
$subtypeIndex = $index;
break;
}
if (! $token instanceof ListData) {
continue;
}
$childPartNumber = $partNumber ? "{$partNumber}.{$childIndex}" : (string) $childIndex;
$parts[] = static::isMultipart($token)
? static::fromListData($token, $childPartNumber)
: BodyStructurePart::fromListData($token, $childPartNumber);
$childIndex++;
}
$parameters = [];
if ($subtypeIndex) {
foreach (array_slice($tokens, $subtypeIndex + 1) as $token) {
if ($token instanceof ListData && ! static::isDispositionList($token)) {
$parameters = $token->toKeyValuePairs();
break;
}
}
}
return new static(
$subtypeIndex ? strtolower($tokens[$subtypeIndex]->value) : 'mixed',
$parameters,
$parts
);
}
/**
* Determine if a ListData represents a multipart structure.
*/
protected static function isMultipart(ListData $data): bool
{
return head($data->tokens()) instanceof ListData;
}
/**
* Determine if a ListData represents a disposition (INLINE or ATTACHMENT).
*/
protected static function isDispositionList(ListData $data): bool
{
$tokens = $data->tokens();
if (count($tokens) < 2 || ! isset($tokens[0]) || ! $tokens[0] instanceof Token) {
return false;
}
return in_array(strtoupper($tokens[0]->value), ['INLINE', 'ATTACHMENT']);
}
/**
* Get the multipart subtype (mixed, alternative, related, etc.).
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the content type.
*/
public function contentType(): string
{
return "multipart/{$this->subtype}";
}
/**
* Get the parameters (e.g., boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get the boundary parameter.
*/
public function boundary(): ?string
{
return $this->parameters['boundary'] ?? null;
}
/**
* Get the direct child parts.
*
* @return array<BodyStructurePart|BodyStructureCollection>
*/
public function parts(): array
{
return $this->parts;
}
/**
* Get all parts flattened (including nested parts).
*
* @return BodyStructurePart[]
*/
public function flatten(): array
{
$flattened = [];
foreach ($this->parts as $part) {
if ($part instanceof self) {
$flattened = array_merge($flattened, $part->flatten());
} else {
$flattened[] = $part;
}
}
return $flattened;
}
/**
* Find a part by its part number.
*/
public function find(string $partNumber): BodyStructurePart|BodyStructureCollection|null
{
foreach ($this->parts as $part) {
if ($part instanceof self) {
if ($found = $part->find($partNumber)) {
return $found;
}
} elseif ($part->partNumber() === $partNumber) {
return $part;
}
}
return null;
}
/**
* Get the text/plain part if available.
*/
public function text(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isText()) {
return $part;
}
}
return null;
}
/**
* Get the text/html part if available.
*/
public function html(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isHtml()) {
return $part;
}
}
return null;
}
/**
* Get all attachment parts.
*
* @return BodyStructurePart[]
*/
public function attachments(): array
{
return array_values(array_filter(
$this->flatten(),
fn (BodyStructurePart $part) => $part->isAttachment()
));
}
/**
* Determine if the collection has attachments.
*/
public function hasAttachments(): bool
{
return count($this->attachments()) > 0;
}
/**
* Get the count of attachments.
*/
public function attachmentCount(): int
{
return count($this->attachments());
}
/**
* Get the count of parts.
*/
public function count(): int
{
return count($this->parts);
}
/**
* Get an iterator for the parts.
*/
public function getIterator(): Traversable
{
yield from $this->parts;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'subtype' => $this->subtype,
'parameters' => $this->parameters,
'content_type' => $this->contentType(),
'parts' => array_map(fn (Arrayable $part) => $part->toArray(), $this->parts),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,271 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class BodyStructurePart implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $partNumber,
protected string $type,
protected string $subtype,
protected array $parameters = [],
protected ?string $id = null,
protected ?string $description = null,
protected ?string $encoding = null,
protected ?int $size = null,
protected ?int $lines = null,
protected ?ContentDisposition $disposition = null,
) {}
/**
* Parse a single part BODYSTRUCTURE ListData into a BodyStructurePart.
*/
public static function fromListData(ListData $data, string $partNumber = '1'): static
{
return static::parse($data->tokens(), $partNumber);
}
/**
* Parse a single (non-multipart) part.
*
* @param array<Token|ListData> $tokens
*/
protected static function parse(array $tokens, string $partNumber): static
{
return new static(
partNumber: $partNumber,
type: strtolower(static::tokenValueAt($tokens, 0) ?? 'text'),
subtype: strtolower(static::tokenValueAt($tokens, 1) ?? 'plain'),
parameters: isset($tokens[2]) && $tokens[2] instanceof ListData ? $tokens[2]->toKeyValuePairs() : [],
id: static::tokenValueAt($tokens, 3),
description: static::tokenValueAt($tokens, 4),
encoding: static::tokenValueAt($tokens, 5),
size: static::tokenIntValueAt($tokens, 6),
lines: static::tokenIntValueAt($tokens, 7),
disposition: ContentDisposition::parse($tokens),
);
}
/**
* Safely read a scalar token value from the parsed body structure.
*
* @param array<Token|ListData> $tokens
*/
protected static function tokenValueAt(array $tokens, int $index): ?string
{
$token = $tokens[$index] ?? null;
if (! $token instanceof Token || $token instanceof Nil) {
return null;
}
return $token->value;
}
/**
* Safely read an integer token value from the parsed body structure.
*
* @param array<Token|ListData> $tokens
*/
protected static function tokenIntValueAt(array $tokens, int $index): ?int
{
$value = static::tokenValueAt($tokens, $index);
return $value === null ? null : (int) $value;
}
/**
* Get the part number (e.g., "1", "1.2", "2.1.3").
*/
public function partNumber(): string
{
return $this->partNumber;
}
/**
* Get the MIME type (e.g., "text", "image", "multipart").
*/
public function type(): string
{
return $this->type;
}
/**
* Get the MIME subtype (e.g., "plain", "html", "jpeg", "mixed").
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the full content type (e.g., "text/plain", "multipart/alternative").
*/
public function contentType(): string
{
return "{$this->type}/{$this->subtype}";
}
/**
* Get the parameters (e.g., charset, boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get a specific parameter value.
*/
public function parameter(string $name): ?string
{
return $this->parameters[strtolower($name)] ?? null;
}
/**
* Get the content ID.
*/
public function id(): ?string
{
return $this->id;
}
/**
* Get the content description.
*/
public function description(): ?string
{
return $this->description;
}
/**
* Get the content transfer encoding.
*/
public function encoding(): ?string
{
return $this->encoding;
}
/**
* Get the size in bytes.
*/
public function size(): ?int
{
return $this->size;
}
/**
* Get the number of lines (for text parts).
*/
public function lines(): ?int
{
return $this->lines;
}
/**
* Get the content disposition.
*/
public function disposition(): ?ContentDisposition
{
return $this->disposition;
}
/**
* Get the filename from disposition parameters.
*/
public function filename(): ?string
{
return $this->disposition?->filename() ?? $this->parameters['name'] ?? null;
}
/**
* Get the charset from parameters.
*/
public function charset(): ?string
{
return $this->parameters['charset'] ?? null;
}
/**
* Determine if this is a text part.
*/
public function isText(): bool
{
return $this->type === 'text' && $this->subtype === 'plain';
}
/**
* Determine if this is an HTML part.
*/
public function isHtml(): bool
{
return $this->type === 'text' && $this->subtype === 'html';
}
/**
* Determine if this is an attachment.
*/
public function isAttachment(): bool
{
if ($this->disposition?->isAttachment()) {
return true;
}
// Inline parts are not attachments.
if ($this->disposition?->isInline()) {
return false;
}
// Consider non-text/html parts with filenames as attachments.
if ($this->filename() && ! $this->isText() && ! $this->isHtml()) {
return true;
}
return false;
}
/**
* Determine if this is an inline part.
*/
public function isInline(): bool
{
return $this->disposition?->isInline() ?? false;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'size' => $this->size,
'lines' => $this->lines,
'subtype' => $this->subtype,
'encoding' => $this->encoding,
'parameters' => $this->parameters,
'part_number' => $this->partNumber,
'description' => $this->description,
'content_type' => $this->contentType(),
'disposition' => $this->disposition?->toArray(),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,11 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\FolderInterface;
use Illuminate\Support\Collection;
/**
* @template-extends Collection<array-key, FolderInterface>
*/
class FolderCollection extends Collection {}
@@ -0,0 +1,32 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Message;
use DirectoryTree\ImapEngine\MessageInterface;
/**
* @template-extends PaginatedCollection<array-key, MessageInterface|Message>
*/
class MessageCollection extends PaginatedCollection
{
/**
* Find a message by its UID.
*/
public function find(int $uid): ?MessageInterface
{
return $this->first(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
/**
* Find a message by its UID or throw an exception.
*/
public function findOrFail(int $uid): MessageInterface
{
return $this->firstOrFail(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
}
@@ -0,0 +1,56 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
* @template TValue
*
* @template-extends Collection<TKey, TValue>
*/
class PaginatedCollection extends Collection
{
/**
* The total number of items.
*/
protected int $total = 0;
/**
* Paginate the current collection.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
public function paginate(int $perPage = 15, ?int $page = null, string $pageName = 'page', bool $prepaginated = false): LengthAwarePaginator
{
$total = $this->total ?: $this->count();
$results = ! $prepaginated && $total ? $this->forPage($page, $perPage) : $this;
return $this->paginator($results, $total, $perPage, $page, $pageName);
}
/**
* Create a new length-aware paginator instance.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
protected function paginator(Collection $items, int $total, int $perPage, ?int $currentPage, string $pageName): LengthAwarePaginator
{
return new LengthAwarePaginator($items, $total, $perPage, $currentPage, $pageName);
}
/**
* Get or set the total amount.
*/
public function total(?int $total = null): ?int
{
if (is_null($total)) {
return $this->total;
}
return $this->total = $total;
}
}
@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
*
* @template-covariant TValue
*
* @extends Collection<array-key, TValue>
*/
class ResponseCollection extends Collection
{
/**
* Filter the collection to only tagged responses.
*
* @return self<array-key, TaggedResponse>
*/
public function tagged(): self
{
return $this->whereInstanceOf(TaggedResponse::class);
}
/**
* Filter the collection to only untagged responses.
*
* @return self<array-key, UntaggedResponse>
*/
public function untagged(): self
{
return $this->whereInstanceOf(UntaggedResponse::class);
}
/**
* Filter the collection to only continuation responses.
*
* @return self<array-key, ContinuationResponse>
*/
public function continuation(): self
{
return $this->whereInstanceOf(ContinuationResponse::class);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine;
trait ComparesFolders
{
/**
* Determine if two folders are the same.
*/
protected function isSameFolder(FolderInterface $a, FolderInterface $b): bool
{
return $a->path() === $b->path()
&& $a->mailbox()->config('host') === $b->mailbox()->config('host')
&& $a->mailbox()->config('username') === $b->mailbox()->config('username');
}
}
@@ -0,0 +1,344 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use Generator;
interface ConnectionInterface
{
/**
* Open a new connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-state-and-flow-diagram
*/
public function connect(string $host, ?int $port = null, array $options = []): void;
/**
* Close the current connection.
*/
public function disconnect(): void;
/**
* Determine if the current session is connected.
*/
public function connected(): bool;
/**
* Send a "LOGIN" command.
*
* Login to a new session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-login-command
*/
public function login(string $user, string $password): TaggedResponse;
/**
* Send a "LOGOUT" command.
*
* Logout of the current server session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-logout-command
*/
public function logout(): void;
/**
* Send an "AUTHENTICATE" command.
*
* Authenticate the current session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-authenticate-command
*/
public function authenticate(string $user, string $token): TaggedResponse;
/**
* Send a "STARTTLS" command.
*
* Upgrade the current plaintext connection to a secure TLS-encrypted connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-starttls-command
*/
public function startTls(): void;
/**
* Send an "IDLE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-idle-command
*/
public function idle(int $timeout): Generator;
/**
* Send a "DONE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13
*/
public function done(): void;
/**
* Send a "NOOP" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command
*/
public function noop(): TaggedResponse;
/**
* Send a "EXPUNGE" command.
*
* Apply session saved changes to the server.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command
*/
public function expunge(): ResponseCollection;
/**
* Send a "CAPABILITY" command.
*
* Get the mailbox's available capabilities.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-capability-command
*/
public function capability(): UntaggedResponse;
/**
* Send a "SEARCH" command.
*
* Execute a search request.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command
*/
public function search(array $params): UntaggedResponse;
/**
* Send a "SORT" command.
*
* Execute a sort request using RFC 5256.
*
* @see https://datatracker.ietf.org/doc/html/rfc5256
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse;
/**
* Send a "FETCH" command.
*
* Exchange identification information.
*
* @see https://datatracker.ietf.org/doc/html/rfc2971.
*/
public function id(?array $ids = null): UntaggedResponse;
/**
* Send a "FETCH UID" command.
*
* Fetch message UIDs using the given message numbers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection;
/**
* Send a "FETCH BODY[TEXT]" command.
*
* Fetch message text contents.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODY[HEADER]" command.
*
* Fetch message headers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODYSTRUCTURE" command.
*
* Fetch message body structure.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyStructure(int|array $ids): ResponseCollection;
/**
* Send a "FETCH BODY[i]" command.
*
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection;
/**
* Send a "FETCH FLAGS" command.
*
* Fetch a message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17
*/
public function flags(int|array $ids): ResponseCollection;
/**
* Send a "FETCH" command.
*
* Fetch one or more items for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-fetch-command
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection;
/**
* Send a "RFC822.SIZE" command.
*
* Fetch message sizes for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21
*/
public function size(int|array $ids): ResponseCollection;
/**
* Send an IMAP command.
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void;
/**
* Send a "SELECT" command.
*
* Select the specified folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-select-command
*/
public function select(string $folder): ResponseCollection;
/**
* Send a "EXAMINE" command.
*
* Examine a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command
*/
public function examine(string $folder): ResponseCollection;
/**
* Send a "LIST" command.
*
* Get a list of available folders.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection;
/**
* Send a "STATUS" command.
*
* Get the status of a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-status-command
*/
public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse;
/**
* Send a "STORE" command.
*
* Set message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-store-command
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection;
/**
* Send a "APPEND" command.
*
* Append a new message to given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse;
/**
* Send a "UID COPY" command.
*
* Copy message set from current folder to other folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "UID MOVE" command.
*
* Move a message set from current folder to another folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "CREATE" command.
*
* Create a new folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-create-command
*/
public function create(string $folder): ResponseCollection;
/**
* Send a "DELETE" command.
*
* Delete a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-delete-command
*/
public function delete(string $folder): TaggedResponse;
/**
* Send a "RENAME" command.
*
* Rename an existing folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-rename-command
*/
public function rename(string $oldPath, string $newPath): TaggedResponse;
/**
* Send a "SUBSCRIBE" command.
*
* Subscribe to a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-subscribe-command
*/
public function subscribe(string $folder): TaggedResponse;
/**
* Send a "UNSUBSCRIBE" command.
*
* Unsubscribe from a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-unsubscribe-command
*/
public function unsubscribe(string $folder): TaggedResponse;
/**
* Send a "GETQUOTA" command.
*
* Retrieve quota information about a specific quota root.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquota
*/
public function quota(string $root): UntaggedResponse;
/**
* Send a "GETQUOTAROOT" command.
*
* Retrieve quota root information about a mailbox.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquotaroot
*/
public function quotaRoot(string $mailbox): ResponseCollection;
}
@@ -0,0 +1,105 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class ImapCommand implements Stringable
{
/**
* The compiled command lines.
*
* @var string[]
*/
protected ?array $compiled = null;
/**
* Constructor.
*/
public function __construct(
protected string $tag,
protected string $command,
protected array $tokens = [],
) {}
/**
* Get the IMAP tag.
*/
public function tag(): string
{
return $this->tag;
}
/**
* Get the IMAP command.
*/
public function command(): string
{
return $this->command;
}
/**
* Get the IMAP tokens.
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Compile the command into lines for transmission.
*
* @return string[]
*/
public function compile(): array
{
if (is_array($this->compiled)) {
return $this->compiled;
}
$lines = [];
$line = trim("{$this->tag} {$this->command}");
foreach ($this->tokens as $token) {
if (is_array($token)) {
// For tokens provided as arrays, the first element is a placeholder
// (for example, "{20}") that signals a literal value will follow.
// The second element holds the actual literal content.
[$placeholder, $literal] = $token;
$lines[] = "{$line} {$placeholder}";
$line = $literal;
} else {
$line .= " {$token}";
}
}
$lines[] = $line;
return $this->compiled = $lines;
}
/**
* Get a redacted version of the command for safe exposure.
*/
public function redacted(): ImapCommand
{
return new static($this->tag, $this->command, array_map(
function (mixed $token) {
return is_array($token)
? array_map(fn () => '[redacted]', $token)
: '[redacted]';
}, $this->tokens)
);
}
/**
* Get the command as a string.
*/
public function __toString(): string
{
return implode("\r\n", $this->compile());
}
}
@@ -0,0 +1,815 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Streams\FakeStream;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use DirectoryTree\ImapEngine\Exceptions\ImapResponseException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
use DirectoryTree\ImapEngine\Support\Str;
use Exception;
use Generator;
use LogicException;
use Throwable;
class ImapConnection implements ConnectionInterface
{
/**
* Sequence number used to generate unique command tags.
*/
protected int $sequence = 0;
/**
* The result instance.
*/
protected ?Result $result = null;
/**
* The parser instance.
*/
protected ?ImapParser $parser = null;
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream,
protected ?LoggerInterface $logger = null,
) {}
/**
* Create a new connection with a fake stream.
*/
public static function fake(array $responses = []): static
{
$stream = new FakeStream;
$stream->open();
$stream->feed($responses);
return new static($stream);
}
/**
* Tear down the connection.
*/
public function __destruct()
{
if (! $this->connected()) {
return;
}
try {
@$this->logout();
} catch (Exception $e) {
// Do nothing.
}
}
/**
* {@inheritDoc}
*/
public function connect(string $host, ?int $port = null, array $options = []): void
{
$transport = strtolower($options['encryption'] ?? '') ?: 'tcp';
if (in_array($transport, ['ssl', 'tls'])) {
$port ??= 993;
} else {
$port ??= 143;
}
$this->setParser(
$this->newParser($this->stream)
);
$this->stream->open(
$transport === 'starttls' ? 'tcp' : $transport,
$host,
$port,
$options['timeout'] ?? 30,
$this->getDefaultSocketOptions(
$transport,
$options['proxy'] ?? [],
$options['validate_cert'] ?? true
)
);
$this->assertNextResponse(
fn (Response $response) => $response instanceof UntaggedResponse,
fn (UntaggedResponse $response) => $response->type()->is('OK'),
fn () => new ImapConnectionFailedException("Connection to $host:$port failed")
);
if ($transport === 'starttls') {
$this->startTls();
}
}
/**
* Get the default socket options for the given transport.
*
* @param 'ssl'|'tls'|'starttls'|'tcp' $transport
*/
protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array
{
$options = [];
$key = match ($transport) {
'ssl', 'tls' => 'ssl',
'starttls', 'tcp' => 'tcp',
};
if (in_array($transport, ['ssl', 'tls'])) {
$options[$key] = [
'verify_peer' => $validateCert,
'verify_peer_name' => $validateCert,
];
}
if (! isset($proxy['socket'])) {
return $options;
}
$options[$key]['proxy'] = $proxy['socket'];
$options[$key]['request_fulluri'] = $proxy['request_fulluri'] ?? false;
if (isset($proxy['username'])) {
$auth = base64_encode($proxy['username'].':'.$proxy['password']);
$options[$key]['header'] = ["Proxy-Authorization: Basic $auth"];
}
return $options;
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
$this->stream->close();
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return $this->stream->opened();
}
/**
* {@inheritDoc}
*/
public function login(string $user, string $password): TaggedResponse
{
$this->send('LOGIN', Str::literal([$user, $password]), $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function logout(): void
{
$this->send('LOGOUT', tag: $tag);
}
/**
* {@inheritDoc}
*/
public function authenticate(string $user, string $token): TaggedResponse
{
$this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function startTls(): void
{
$this->send('STARTTLS', tag: $tag);
$this->assertTaggedResponse($tag);
$this->stream->setSocketSetCrypto(true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
}
/**
* {@inheritDoc}
*/
public function select(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('SELECT', $folder);
}
/**
* {@inheritDoc}
*/
public function examine(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('EXAMINE', $folder);
}
/**
* Examine and select have the same response.
*/
protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): ResponseCollection
{
$this->send($command, [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse
{
$this->send('STATUS', [
Str::literal($folder),
Str::list($arguments),
], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstWhere(
fn (UntaggedResponse $response) => $response->type()->is('STATUS')
);
}
/**
* {@inheritDoc}
*/
public function create(string $folder): ResponseCollection
{
$this->send('CREATE', [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function delete(string $folder): TaggedResponse
{
$this->send('DELETE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function rename(string $oldPath, string $newPath): TaggedResponse
{
$this->send('RENAME', Str::literal([$oldPath, $newPath]), tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function subscribe(string $folder): TaggedResponse
{
$this->send('SUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function unsubscribe(string $folder): TaggedResponse
{
$this->send('UNSUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function quota(string $root): UntaggedResponse
{
$this->send('GETQUOTA', [Str::literal($root)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function quotaRoot(string $mailbox): ResponseCollection
{
$this->send('GETQUOTAROOT', [Str::literal($mailbox)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection
{
$this->send('LIST', Str::literal([$reference, $folder]), $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse
{
$tokens = [];
$tokens[] = Str::literal($folder);
if ($flags) {
$tokens[] = Str::list($flags);
}
$tokens[] = Str::literal($message);
$this->send('APPEND', $tokens, tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID COPY', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID MOVE', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection
{
$set = Str::set($from, $to);
$flags = Str::list((array) $flags);
$item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : '');
$this->send('UID STORE', [$set, $item, $flags], tag: $tag);
$this->assertTaggedResponse($tag);
return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('FETCH')
);
}
/**
* {@inheritDoc}
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection
{
return $this->fetch(['UID'], (array) $ids, null, $identifier);
}
/**
* {@inheritDoc}
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids);
}
/**
* Fetch the BODYSTRUCTURE for the given message(s).
*/
public function bodyStructure(int|array $ids): ResponseCollection
{
return $this->fetch(['BODYSTRUCTURE'], (array) $ids);
}
/**
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection
{
$part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]";
return $this->fetch([$part], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function flags(int|array $ids): ResponseCollection
{
return $this->fetch(['FLAGS'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function size(int|array $ids): ResponseCollection
{
return $this->fetch(['RFC822.SIZE'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function search(array $params): UntaggedResponse
{
$this->send('UID SEARCH', $params, tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SEARCH')
);
}
/**
* {@inheritDoc}
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse
{
$sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value;
$this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SORT')
);
}
/**
* {@inheritDoc}
*/
public function capability(): UntaggedResponse
{
$this->send('CAPABILITY', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('CAPABILITY')
);
}
/**
* {@inheritDoc}
*/
public function id(?array $ids = null): UntaggedResponse
{
$token = 'NIL';
if (is_array($ids) && ! empty($ids)) {
$token = '(';
foreach ($ids as $id) {
$token .= '"'.Str::escape($id).'" ';
}
$token = rtrim($token).')';
}
$this->send('ID', [$token], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('ID')
);
}
/**
* {@inheritDoc}
*/
public function expunge(): ResponseCollection
{
$this->send('EXPUNGE', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function noop(): TaggedResponse
{
$this->send('NOOP', tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function idle(int $timeout): Generator
{
$this->stream->setTimeout($timeout);
$this->send('IDLE', tag: $tag);
$this->assertNextResponse(
fn (Response $response) => $response instanceof ContinuationResponse,
fn (ContinuationResponse $response) => true,
fn (ContinuationResponse $response) => ImapCommandException::make(new ImapCommand('', 'IDLE'), $response),
);
while ($response = $this->nextReply()) {
yield $response;
}
}
/**
* {@inheritDoc}
*/
public function done(): void
{
$this->write('DONE');
// After issuing a "DONE" command, the server must eventually respond with a
// tagged response to indicate that the IDLE command has been successfully
// terminated and the server is ready to accept further commands.
$this->assertNextResponse(
fn (Response $response) => $response instanceof TaggedResponse,
fn (TaggedResponse $response) => $response->successful(),
fn (TaggedResponse $response) => ImapCommandException::make(new ImapCommand('', 'DONE'), $response),
);
}
/**
* Send an IMAP command.
*
* @param-out string $tag
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void
{
if (! $tag) {
$this->sequence++;
$tag = 'TAG'.$this->sequence;
}
$command = new ImapCommand($tag, $name, $tokens);
// After every command, we'll overwrite any previous result
// with the new command and its responses, so that we can
// easily access the commands responses for assertion.
$this->setResult(new Result($command));
foreach ($command->compile() as $line) {
$this->write($line);
}
}
/**
* Write data to the connected stream.
*/
protected function write(string $data): void
{
if ($this->stream->fwrite($data."\r\n") === false) {
throw new ImapStreamException('Failed to write data to stream');
}
$this->logger?->sent($data);
}
/**
* Fetch one or more items for one or more messages.
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
{
$prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : '';
$this->send(trim($prefix.' FETCH'), [
Str::set($from, $to),
Str::list((array) $items),
], $tag);
$this->assertTaggedResponse($tag);
// Some IMAP servers can send unsolicited untagged responses along with fetch
// requests. We'll need to filter these out so that we can return only the
// responses that are relevant to the fetch command. For example:
// >> TAG123 FETCH (UID 456 BODY[TEXT])
// << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!)
// << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response
return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) {
// Skip over any untagged responses that are not FETCH responses.
// The third token should always be the list of data items.
if (! ($data = $response->tokenAt(3)) instanceof ListData) {
return false;
}
return match ($identifier) {
// If we're fetching UIDs, we can check if a UID token is contained in the list.
ImapFetchIdentifier::Uid => $data->contains('UID'),
// If we're fetching message numbers, we can check if the requested items are all contained in the list.
ImapFetchIdentifier::MessageNumber => $data->contains($items),
};
});
}
/**
* Set the current result instance.
*/
protected function setResult(Result $result): void
{
$this->result = $result;
}
/**
* Set the current parser instance.
*/
protected function setParser(ImapParser $parser): void
{
$this->parser = $parser;
}
/**
* Create a new parser instance.
*/
protected function newParser(StreamInterface $stream): ImapParser
{
return new ImapParser($this->newTokenizer($stream));
}
/**
* Create a new tokenizer instance.
*/
protected function newTokenizer(StreamInterface $stream): ImapTokenizer
{
return new ImapTokenizer($stream);
}
/**
* Assert the next response is a successful tagged response.
*/
protected function assertTaggedResponse(string $tag, ?callable $exception = null): TaggedResponse
{
/** @var TaggedResponse $response */
$response = $this->assertNextResponse(
fn (Response $response) => (
$response instanceof TaggedResponse && $response->tag()->is($tag)
),
fn (TaggedResponse $response) => (
$response->successful()
),
$exception ?? fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command(), $response)
),
);
return $response;
}
/**
* Assert the next response matches the given filter and assertion.
*
* @template T of Response
*
* @param callable(Response): bool $filter
* @param callable(T): bool $assertion
* @param callable(T): Throwable $exception
* @return T
*
* @throws ImapResponseException
*/
protected function assertNextResponse(callable $filter, callable $assertion, callable $exception): Response
{
while ($response = $this->nextResponse($filter)) {
if ($assertion($response)) {
return $response;
}
throw $exception($response);
}
throw new ImapResponseException('No matching response found');
}
/**
* Returns the next response matching the given filter.
*
* @template T of Response
*
* @param callable(T): bool $filter
* @return T|null
*/
protected function nextResponse(callable $filter): ?Response
{
if (! $this->parser) {
throw new LogicException('No parser instance set');
}
while ($response = $this->nextReply()) {
if (! $response instanceof Response) {
continue;
}
$this->result?->addResponse($response);
if ($filter($response)) {
return $response;
}
}
return null;
}
/**
* Read the next reply from the stream.
*/
protected function nextReply(): Data|Token|Response|null
{
if (! $reply = $this->parser->next()) {
$meta = $this->stream->meta();
throw match (true) {
$meta['timed_out'] ?? false => new ImapConnectionTimedOutException('Stream timed out, no response'),
$meta['eof'] ?? false => new ImapConnectionClosedException('Server closed the connection (EOF)'),
default => new ImapConnectionFailedException('Unknown stream error. Metadata: '.json_encode($meta)),
};
}
$this->logger?->received($reply);
return $reply;
}
}
@@ -0,0 +1,270 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
class ImapParser
{
/**
* The current token being parsed.
*
* Expected to be an associative array with keys like "type" and "value".
*/
protected ?Token $currentToken = null;
/**
* Constructor.
*/
public function __construct(
protected ImapTokenizer $tokenizer
) {}
/**
* Get the next response from the tokenizer.
*/
public function next(): Data|Token|Response|null
{
// Attempt to load the first token.
if (! $this->currentToken) {
$this->advance();
}
// No token was found, return null.
if (! $this->currentToken) {
return null;
}
// If the token indicates the beginning of a list, parse it.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token is an Atom or Number, check its value for special markers.
if ($this->currentToken instanceof Atom || $this->currentToken instanceof Number) {
// '*' marks an untagged response.
if ($this->currentToken->value === '*') {
return $this->parseUntaggedResponse();
}
// '+' marks a continuation response.
if ($this->currentToken->value === '+') {
return $this->parseContinuationResponse();
}
// If it's an ATOM and not '*' or '+', it's likely a tagged response.
return $this->parseTaggedResponse();
}
return $this->parseElement();
}
/**
* Parse an untagged response.
*
* An untagged response begins with the '*' token. It may contain
* multiple elements, including lists and response codes.
*/
protected function parseUntaggedResponse(): UntaggedResponse
{
// Capture the initial '*' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the end-of-response marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// If the end-of-response marker (CRLF) is present, consume it.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated untagged response');
}
return new UntaggedResponse($elements);
}
/**
* Parse a continuation response.
*
* A continuation response starts with a '+' token, indicating
* that the server expects additional data from the client.
*/
protected function parseContinuationResponse(): ContinuationResponse
{
// Capture the initial '+' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the CRLF marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated continuation response');
}
return new ContinuationResponse($elements);
}
/**
* Parse a tagged response.
*
* A tagged response begins with a tag (which is not '*' or '+')
* and is followed by a status and optional data.
*/
protected function parseTaggedResponse(): TaggedResponse
{
// Capture the initial TAG token.
$tokens[] = clone $this->currentToken;
$this->advance();
// Collect tokens until the end-of-response marker is reached.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$tokens[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated tagged response');
}
return new TaggedResponse($tokens);
}
/**
* Parses a bracket group of elements delimited by '[' and ']'.
*
* Bracket groups are used to represent response codes.
*/
protected function parseBracketGroup(): ResponseCodeData
{
// Consume the opening '[' token.
$this->advance();
$elements = [];
while (
$this->currentToken
&& ! $this->currentToken instanceof ResponseCodeClose
) {
// Skip CRLF tokens that may appear inside bracket groups.
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated bracket group in response');
}
// Consume the closing ']' token.
$this->advance();
return new ResponseCodeData($elements);
}
/**
* Parses a list of elements delimited by '(' and ')'.
*
* Lists are handled recursively, as a list may contain nested lists.
*/
protected function parseList(): ListData
{
// Consume the opening '(' token.
$this->advance();
$elements = [];
// Continue to parse elements until we find the corresponding ')'.
while (
$this->currentToken
&& ! $this->currentToken instanceof ListClose
) {
// Skip CRLF tokens that appear inside lists (after literals).
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
// If we reached the end without finding a closing ')', throw an exception.
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated list in response');
}
// Consume the closing ')' token.
$this->advance();
return new ListData($elements);
}
/**
* Parses a single element, which might be a list or a simple token.
*/
protected function parseElement(): Data|Token|null
{
// If there is no current token, return null.
if ($this->currentToken === null) {
return null;
}
// If the token indicates the start of a list, parse it as a list.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token indicates the start of a group, parse it as a group.
if ($this->currentToken instanceof ResponseCodeOpen) {
return $this->parseBracketGroup();
}
// Otherwise, capture the current token.
$token = clone $this->currentToken;
$this->advance();
return $token;
}
/**
* Advance to the next token from the tokenizer.
*/
protected function advance(): void
{
$this->currentToken = $this->tokenizer->nextToken();
}
}
@@ -0,0 +1,510 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use BackedEnum;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
use DirectoryTree\ImapEngine\Support\Str;
class ImapQueryBuilder
{
/**
* The where conditions for the query.
*/
protected array $wheres = [];
/**
* The date format to use for date based queries.
*/
protected string $dateFormat = 'd-M-Y';
/**
* Add a where "ALL" clause to the query.
*/
public function all(): static
{
return $this->where(ImapSearchKey::All);
}
/**
* Add a where "NEW" clause to the query.
*/
public function new(): static
{
return $this->where(ImapSearchKey::New);
}
/**
* Add a where "OLD" clause to the query.
*/
public function old(): static
{
return $this->where(ImapSearchKey::Old);
}
/**
* Add a where "SEEN" clause to the query.
*/
public function seen(): static
{
return $this->where(ImapSearchKey::Seen);
}
/**
* Add a where "DRAFT" clause to the query.
*/
public function draft(): static
{
return $this->where(ImapSearchKey::Draft);
}
/**
* Add a where "RECENT" clause to the query.
*/
public function recent(): static
{
return $this->where(ImapSearchKey::Recent);
}
/**
* Add a where "UNSEEN" clause to the query.
*/
public function unseen(): static
{
return $this->where(ImapSearchKey::Unseen);
}
/**
* Add a where "FLAGGED" clause to the query.
*/
public function flagged(): static
{
return $this->where(ImapSearchKey::Flagged);
}
/**
* Add a where "DELETED" clause to the query.
*/
public function deleted(): static
{
return $this->where(ImapSearchKey::Deleted);
}
/**
* Add a where "ANSWERED" clause to the query.
*/
public function answered(): static
{
return $this->where(ImapSearchKey::Answered);
}
/**
* Add a where "UNDELETED" clause to the query.
*/
public function undeleted(): static
{
return $this->where(ImapSearchKey::Undeleted);
}
/**
* Add a where "UNFLAGGED" clause to the query.
*/
public function unflagged(): static
{
return $this->where(ImapSearchKey::Unflagged);
}
/**
* Add a where "UNANSWERED" clause to the query.
*/
public function unanswered(): static
{
return $this->where(ImapSearchKey::Unanswered);
}
/**
* Add a where "FROM" clause to the query.
*/
public function from(string $email): static
{
return $this->where(ImapSearchKey::From, $email);
}
/**
* Add a where "TO" clause to the query.
*/
public function to(string $value): static
{
return $this->where(ImapSearchKey::To, $value);
}
/**
* Add a where "CC" clause to the query.
*/
public function cc(string $value): static
{
return $this->where(ImapSearchKey::Cc, $value);
}
/**
* Add a where "BCC" clause to the query.
*/
public function bcc(string $value): static
{
return $this->where(ImapSearchKey::Bcc, $value);
}
/**
* Add a where "BODY" clause to the query.
*/
public function body(string $value): static
{
return $this->where(ImapSearchKey::Body, $value);
}
/**
* Add a where "KEYWORD" clause to the query.
*/
public function keyword(string $value): static
{
return $this->where(ImapSearchKey::Keyword, $value);
}
/**
* Add a where "UNKEYWORD" clause to the query.
*/
public function unkeyword(string $value): static
{
return $this->where(ImapSearchKey::Unkeyword, $value);
}
/**
* Add a where "ON" clause to the query.
*/
public function on(mixed $date): static
{
return $this->where(ImapSearchKey::On, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SINCE" clause to the query.
*/
public function since(mixed $date): static
{
return $this->where(ImapSearchKey::Since, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "BEFORE" clause to the query.
*/
public function before(mixed $value): static
{
return $this->where(ImapSearchKey::Before, new RawQueryValue(
$this->parseDate($value)->format($this->dateFormat)
));
}
/**
* Add a where "SENTON" clause to the query.
*/
public function sentOn(mixed $date): static
{
return $this->where(ImapSearchKey::SentOn, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTSINCE" clause to the query.
*/
public function sentSince(mixed $date): static
{
return $this->where(ImapSearchKey::SentSince, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTBEFORE" clause to the query.
*/
public function sentBefore(mixed $date): static
{
return $this->where(ImapSearchKey::SentBefore, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SUBJECT" clause to the query.
*/
public function subject(string $value): static
{
return $this->where(ImapSearchKey::Subject, $value);
}
/**
* Add a where "TEXT" clause to the query.
*/
public function text(string $value): static
{
return $this->where(ImapSearchKey::Text, $value);
}
/**
* Add a where "HEADER" clause to the query.
*/
public function header(string $header, string $value): static
{
return $this->where(ImapSearchKey::Header->value." $header", $value);
}
/**
* Add a where "UID" clause to the query.
*/
public function uid(int|string|array $from, int|float|null $to = null): static
{
return $this->where(ImapSearchKey::Uid, new RawQueryValue(Str::set($from, $to)));
}
/**
* Add a where "LARGER" clause to the query.
*/
public function larger(int $bytes): static
{
return $this->where(ImapSearchKey::Larger, new RawQueryValue($bytes));
}
/**
* Add a where "SMALLER" clause to the query.
*/
public function smaller(int $bytes): static
{
return $this->where(ImapSearchKey::Smaller, new RawQueryValue($bytes));
}
/**
* Add a "where" condition.
*/
public function where(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('AND', $column);
} else {
$this->addBasicCondition('AND', $column, $value);
}
return $this;
}
/**
* Add an "or where" condition.
*/
public function orWhere(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('OR', $column);
} else {
$this->addBasicCondition('OR', $column, $value);
}
return $this;
}
/**
* Add a "where not" condition.
*/
public function whereNot(mixed $column, mixed $value = null): static
{
$this->addBasicCondition('AND', $column, $value, true);
return $this;
}
/**
* Determine if the query has any where conditions.
*/
public function isEmpty(): bool
{
return empty($this->wheres);
}
/**
* Transform the instance into an IMAP-compatible query string.
*/
public function toImap(): string
{
return $this->compileWheres($this->wheres);
}
/**
* Create a new query instance (like Eloquent's newQuery).
*/
protected function newQuery(): static
{
return new static;
}
/**
* Add a basic condition to the query.
*/
protected function addBasicCondition(string $boolean, mixed $column, mixed $value, bool $not = false): void
{
$value = $this->prepareWhereValue($value);
$column = Str::enum($column);
$this->wheres[] = [
'type' => 'basic',
'not' => $not,
'key' => $column,
'value' => $value,
'boolean' => $boolean,
];
}
/**
* Prepare the where value, escaping it as needed.
*/
protected function prepareWhereValue(mixed $value): RawQueryValue|string|null
{
if (is_null($value)) {
return null;
}
if ($value instanceof RawQueryValue) {
return $value;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof DateTimeInterface) {
$value = Carbon::instance($value);
}
if ($value instanceof CarbonInterface) {
$value = $value->format($this->dateFormat);
}
return Str::escape($value);
}
/**
* Add a nested condition group to the query.
*/
protected function addNestedCondition(string $boolean, callable $callback): void
{
$nested = $this->newQuery();
$callback($nested);
$this->wheres[] = [
'type' => 'nested',
'query' => $nested,
'boolean' => $boolean,
];
}
/**
* Attempt to parse a date string into a Carbon instance.
*/
protected function parseDate(mixed $date): CarbonInterface
{
if ($date instanceof CarbonInterface) {
return $date;
}
return Carbon::parse($date);
}
/**
* Build a single expression node from a basic or nested where.
*
* @param array{type: 'basic'|'nested', boolean: 'AND'|'OR', query: ImapQueryBuilder} $where
*/
protected function makeExpressionNode(array $where): array
{
return match ($where['type']) {
'basic' => [
'expr' => $this->compileBasic($where),
'boolean' => $where['boolean'],
],
'nested' => [
'expr' => $where['query']->toImap(),
'boolean' => $where['boolean'],
]
};
}
/**
* Merge the existing expression with the next expression, respecting the boolean operator.
*
* @param 'AND'|'OR' $boolean
*/
protected function mergeExpressions(string $existing, string $next, string $boolean): string
{
return match ($boolean) {
// AND is implicit just append.
'AND' => $existing.' '.$next,
// IMAP's OR is binary; nest accordingly.
'OR' => 'OR ('.$existing.') ('.$next.')',
};
}
/**
* Recursively compile the wheres array into an IMAP-compatible string.
*/
protected function compileWheres(array $wheres): string
{
if (empty($wheres)) {
return '';
}
// Convert each "where" into a node for later merging.
$exprNodes = array_map(fn (array $where) => (
$this->makeExpressionNode($where)
), $wheres);
// Start with the first expression.
$combined = array_shift($exprNodes)['expr'];
// Merge the rest of the expressions.
foreach ($exprNodes as $node) {
$combined = $this->mergeExpressions(
$combined, $node['expr'], $node['boolean']
);
}
return trim($combined);
}
/**
* Compile a basic where condition into an IMAP-compatible string.
*/
protected function compileBasic(array $where): string
{
$part = strtoupper($where['key']);
if ($where['value'] instanceof RawQueryValue) {
$part .= ' '.$where['value']->value;
} elseif ($where['value']) {
$part .= ' "'.Str::toImapUtf7($where['value']).'"';
}
if ($where['not']) {
$part = 'NOT '.$part;
}
return $part;
}
}
@@ -0,0 +1,511 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Literal;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\QuotedString;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
class ImapTokenizer
{
/**
* The current position in the buffer.
*/
protected int $position = 0;
/**
* The buffer of characters read from the stream.
*/
protected string $buffer = '';
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream
) {}
/**
* Returns the next token from the stream.
*/
public function nextToken(): ?Token
{
$this->skipWhitespace();
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null || $char === '') {
return null;
}
// Check for line feed.
if ($char === "\n") {
// With a valid IMAP response, we should never reach this point,
// but in case we receive a malformed response, we will flush
// the buffer and return null to prevent an infinite loop.
$this->flushBuffer();
return null;
}
// Check for carriage return. (\r\n)
if ($char === "\r") {
$this->advance(); // Consume CR
$this->ensureBuffer(1);
if ($this->currentChar() !== "\n") {
throw new ImapParserException('Expected LF after CR');
}
$this->advance(); // Consume LF (\n)
return new Crlf("\r\n");
}
// Check for parameter list opening.
if ($char === '(') {
$this->advance();
return new ListOpen('(');
}
// Check for a parameter list closing.
if ($char === ')') {
$this->advance();
return new ListClose(')');
}
// Check for a response group open.
if ($char === '[') {
$this->advance();
return new ResponseCodeOpen('[');
}
// Check for response group close.
if ($char === ']') {
$this->advance();
return new ResponseCodeClose(']');
}
// Check for angle bracket open (email addresses).
if ($char === '<') {
$this->advance();
return $this->readEmailAddress();
}
// Check for quoted string.
if ($char === '"') {
return $this->readQuotedString();
}
// Check for literal block open.
if ($char === '{') {
return $this->readLiteral();
}
// Otherwise, parse a number or atom.
return $this->readNumberOrAtom();
}
/**
* Skips whitespace characters (spaces and tabs only, preserving CRLF).
*/
protected function skipWhitespace(): void
{
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
// Break on EOF.
if ($char === null || $char === '') {
break;
}
// Break on CRLF.
if ($char === "\r" || $char === "\n") {
break;
}
// Break on non-whitespace.
if ($char !== ' ' && $char !== "\t") {
break;
}
$this->advance();
}
}
/**
* Reads a quoted string token.
*
* Quoted strings are enclosed in double quotes and may contain escaped characters.
*/
protected function readQuotedString(): QuotedString
{
// Skip the opening quote.
$this->advance();
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException(sprintf(
'Unterminated quoted string at buffer offset %d. Buffer: "%s"',
$this->position,
substr($this->buffer, max(0, $this->position - 10), 20)
));
}
if ($char === '\\') {
$this->advance(); // Skip the backslash.
$this->ensureBuffer(1);
$escapedChar = $this->currentChar();
if ($escapedChar === null) {
throw new ImapParserException('Unterminated escape sequence in quoted string');
}
$value .= $escapedChar;
$this->advance();
continue;
}
if ($char === '"') {
$this->advance(); // Skip the closing quote.
break;
}
$value .= $char;
$this->advance();
}
return new QuotedString($value);
}
/**
* Reads a literal token.
*
* Literal blocks in IMAP have the form {<length>}\r\n<data>.
*/
protected function readLiteral(): Literal
{
// Skip the opening '{'.
$this->advance();
// This will contain the size of the literal block in a sequence of digits.
// {<size>}\r\n<data>
$numStr = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated literal specifier');
}
if ($char === '}') {
$this->advance(); // Skip the '}'.
break;
}
$numStr .= $char;
$this->advance();
}
// Expect carriage return after the literal specifier.
$this->ensureBuffer(2);
// Get the carriage return.
$crlf = substr($this->buffer, $this->position, 2);
if ($crlf !== "\r\n") {
throw new ImapParserException('Expected CRLF after literal specifier');
}
// Skip the CRLF.
$this->advance(2);
$length = (int) $numStr;
// Use any data that is already in our buffer.
$available = strlen($this->buffer) - $this->position;
if ($available >= $length) {
$literal = substr($this->buffer, $this->position, $length);
$this->advance($length);
} else {
// Consume whatever is available without flushing the whole buffer.
$literal = substr($this->buffer, $this->position);
$consumed = strlen($literal);
// Advance the pointer by the number of bytes we took.
$this->advance($consumed);
// Calculate how many bytes are still needed.
$remaining = $length - $consumed;
// Read the missing bytes from the stream.
$data = $this->stream->read($remaining);
if ($data === false || strlen($data) !== $remaining) {
throw new ImapStreamException('Unexpected end of stream while trying to fill the buffer');
}
$literal .= $data;
}
// Verify that the literal length matches the expected length.
if (strlen($literal) !== $length) {
throw new ImapParserException(sprintf(
'Literal length mismatch: expected %d, got %d',
$length,
strlen($literal)
));
}
return new Literal($literal);
}
/**
* Reads a number or atom token.
*/
protected function readNumberOrAtom(): Token
{
$position = $this->position;
// First char must be a digit to even consider a number.
if (! ctype_digit($this->buffer[$position] ?? '')) {
return $this->readAtom();
}
// Walk forward to find the end of the digit run.
while (ctype_digit($this->buffer[$position] ?? '')) {
$position++;
$this->ensureBuffer($position - $this->position + 1);
}
$next = $this->buffer[$position] ?? null;
// If next is EOF or a delimiter, it's a Number.
if ($next === null || $this->isDelimiter($next)) {
return $this->readNumber();
}
// Otherwise it's an Atom.
return $this->readAtom();
}
/**
* Reads a number token.
*
* A number consists of one or more digit characters and represents a numeric value.
*/
protected function readNumber(): Number
{
$start = $this->position;
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! ctype_digit($char)) {
break;
}
$this->advance();
}
return new Number(substr($this->buffer, $start, $this->position - $start));
}
/**
* Reads an atom token.
*
* ATOMs are sequences of printable ASCII characters that do not contain delimiters.
*/
protected function readAtom(): Atom
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! $this->isValidAtomCharacter($char)) {
break;
}
$value .= $char;
$this->advance();
}
if (strcasecmp($value, 'NIL') === 0) {
return new Nil($value);
}
return new Atom($value);
}
/**
* Reads an email address token enclosed in angle brackets.
*
* Email addresses are enclosed in angle brackets ("<" and ">").
*
* For example "<johndoe@email.com>"
*/
protected function readEmailAddress(): ?EmailAddress
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated email address, expected ">"');
}
if ($char === '>') {
$this->advance(); // Skip the closing '>'.
break;
}
$value .= $char;
$this->advance();
}
return new EmailAddress($value);
}
/**
* Ensures that at least the given length in characters are available in the buffer.
*/
protected function ensureBuffer(int $length): void
{
// If we have enough data in the buffer, return early.
while ((strlen($this->buffer) - $this->position) < $length) {
$data = $this->stream->fgets();
if ($data === false) {
return;
}
$this->buffer .= $data;
}
}
/**
* Returns the current character in the buffer.
*/
protected function currentChar(): ?string
{
return $this->buffer[$this->position] ?? null;
}
/**
* Advances the internal pointer by $n characters.
*/
protected function advance(int $n = 1): void
{
$this->position += $n;
// If we have consumed the entire buffer, reset it.
if ($this->position >= strlen($this->buffer)) {
$this->flushBuffer();
}
}
/**
* Flush the buffer and reset the position.
*/
protected function flushBuffer(): void
{
$this->buffer = '';
$this->position = 0;
}
/**
* Determine if the given character is a valid atom character.
*/
protected function isValidAtomCharacter(string $char): bool
{
// Get the ASCII code.
$code = ord($char);
// Allow only printable ASCII (32-126).
if ($code < 32 || $code > 126) {
return false;
}
// Delimiters are not allowed inside ATOMs.
if ($this->isDelimiter($char)) {
return false;
}
return true;
}
/**
* Determine if the given character is a delimiter for tokenizing responses.
*/
protected function isDelimiter(string $char): bool
{
// This delimiter list includes additional characters (such as square
// brackets, curly braces, and angle brackets) to ensure that tokens
// like the response code group brackets are split out. This is fine
// for tokenizing responses, even though its more restrictive
// than the IMAP atom definition in RFC 3501 (section 9).
return in_array($char, [' ', '(', ')', '[', ']', '{', '}', '<', '>'], true);
}
}
@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class EchoLogger extends Logger
{
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
echo $message;
}
}
@@ -0,0 +1,21 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class FileLogger extends Logger
{
/**
* Constructor.
*/
public function __construct(
protected string $path
) {}
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
file_put_contents($this->path, $message, FILE_APPEND);
}
}
@@ -0,0 +1,35 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
abstract class Logger implements LoggerInterface
{
/**
* Write a message to the log.
*/
abstract protected function write(string $message): void;
/**
* {@inheritDoc}
*/
public function sent(string $message): void
{
$this->write(sprintf('%s: >> %s', $this->date(), $message).PHP_EOL);
}
/**
* {@inheritDoc}
*/
public function received(string $message): void
{
$this->write(sprintf('%s: << %s', $this->date(), $message).PHP_EOL);
}
/**
* Get the current date and time.
*/
protected function date(): string
{
return date('Y-m-d H:i:s');
}
}
@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
interface LoggerInterface
{
/**
* Log when a message is sent.
*/
public function sent(string $message): void;
/**
* Log when a message is received.
*/
public function received(string $message): void;
}
@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class RayLogger extends Logger
{
/**
* {@inheritDoc}
*/
protected function write(string $message): void
{
ray($message);
}
}
@@ -0,0 +1,15 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class RawQueryValue
{
/**
* Constructor.
*/
public function __construct(
public readonly Stringable|string $value
) {}
}
@@ -0,0 +1,18 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ContinuationResponse extends Response
{
/**
* Get the data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 1);
}
}
@@ -0,0 +1,73 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Responses\HasTokens;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Stringable;
abstract class Data implements Stringable
{
use HasTokens;
/**
* Constructor.
*/
public function __construct(
protected array $tokens
) {}
/**
* Get the tokens.
*
* @return Token[]|Data[]
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Get the first token.
*/
public function first(): Token|Data|null
{
return $this->tokens[0] ?? null;
}
/**
* Get the last token.
*/
public function last(): Token|Data|null
{
return $this->tokens[count($this->tokens) - 1] ?? null;
}
/**
* Determine if the data contains a specific value.
*/
public function contains(array|string $needles): bool
{
$haystack = $this->values();
foreach ((array) $needles as $needle) {
if (! in_array($needle, $haystack)) {
return false;
}
}
return true;
}
/**
* Get all the token's values.
*/
public function values(): array
{
return array_map(function (Token|Data $token) {
return $token instanceof Data
? $token->values()
: $token->value;
}, $this->tokens);
}
}
@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ListData extends Data
{
/**
* Find the immediate successor token of the given field in the list.
*/
public function lookup(string $field): Data|Token|null
{
foreach ($this->tokens as $index => $token) {
if ((string) $token === $field) {
return $this->tokenAt(++$index);
}
}
return null;
}
/**
* Convert alternating key/value tokens to an associative array.
*/
public function toKeyValuePairs(): array
{
$pairs = [];
for ($i = 0; $i < count($this->tokens) - 1; $i += 2) {
$key = strtolower($this->tokens[$i]->value);
$pairs[$key] = $this->tokens[$i + 1]->value;
}
return $pairs;
}
/**
* Get the list as a string.
*/
public function __toString(): string
{
return sprintf('(%s)', implode(
' ', array_map('strval', $this->tokens)
));
}
}

Some files were not shown because too many files have changed in this diff Show More