bkdotcom/PHPDebugConsole

View on GitHub
src/Promise/Promise.php

Summary

Maintainability
A
35 mins
Test Coverage
A
96%
<?php

namespace bdk;

use BadMethodCallException;
use bdk\Promise\Exception\CancellationException;
use bdk\Promise\PromiseInterface;
use Exception;
use LogicException;
use Throwable;

/**
 * Promises/A+ implementation that avoids recursion when possible.
 *
 * @link https://promisesaplus.com/
 *
 * @psalm-consistent-constructor
 */
class Promise implements PromiseInterface
{
    /** @var mixed */
    protected $result;

    /** @var string */
    protected $state = self::PENDING;

    /** @var callable|null */
    protected $waitFn;

    /** @var callable|null */
    private $cancelFn;

    /** @var list<list{Promise, callable|null, callable|null}> */
    private $handlers = array();

    /** @var self[] Promise chain */
    private $waitList = array();

    /**
     * @param callable $waitFn   Fn that when invoked resolves the promise.
     * @param callable $cancelFn Fn that when invoked cancels the promise.
     */
    public function __construct(callable $waitFn = null, callable $cancelFn = null)
    {
        $this->waitFn = $waitFn;
        $this->cancelFn = $cancelFn;
    }

    /**
     * Magic method... inaccessible method called.
     *
     * @param string $method Inaccessible method name
     * @param array  $args   Arguments passed to method
     *
     * @return mixed
     *
     * @throws BadMethodCallException
     *
     * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter
     */
    public function __call($method, $args)
    {
        if (\preg_match('/^is([A-Z][a-z]+)$/', $method)) {
            $args = array($this);
        }
        return $this->__callStatic($method, $args);
    }

    /**
     * Magic method... inaccessible static method called.
     *
     * @param string $method Inaccessible method name
     * @param array  $args   Arguments passed to method
     *
     * @return mixed
     *
     * @throws BadMethodCallException
     */
    public static function __callStatic($method, $args)
    {
        $isClass = 'bdk\\Promise\\Is';
        $utilClasses = array(
            'bdk\\Promise\\Utils',
            'bdk\\Promise\\Create',
        );
        if (\preg_match('/^is([A-Z][a-z]+)$/', $method, $matches) && \method_exists($isClass, \strtolower($matches[1]))) {
            $callable = array($isClass, \strtolower($matches[1]));
            return $callable($args[0]);
        }
        foreach ($utilClasses as $class) {
            if (\method_exists($class, $method)) {
                return \call_user_func_array(array($class, $method), $args);
            }
        }
        throw new BadMethodCallException(\sprintf(
            'Undefined method: %s::%s()',
            \get_called_class(),
            $method
        ));
    }

    /**
     * Appends fulfillment and rejection handlers to the promise, and returns
     * a new promise resolving to the return value of the called handler.
     *
     * @param callable $onFulfilled Invoked when the promise fulfills.
     * @param callable $onRejected  Invoked when the promise is rejected.
     *
     * @return Promise
     */
    public function then(callable $onFulfilled = null, callable $onRejected = null)
    {
        if ($this->state === self::PENDING) {
            $promise = new static(null, array($this, 'cancel'));
            $this->handlers[] = array($promise, $onFulfilled, $onRejected);
            $promise->waitList = $this->waitList;
            $promise->waitList[] = $this;
            return $promise;
        }

        // Return a fulfilled promise and immediately invoke any callbacks.
        if ($this->state === self::FULFILLED) {
            $promise = self::promiseFor($this->result);
            return $onFulfilled
                ? $promise->then($onFulfilled)
                : $promise;
        }

        // It's either cancelled or rejected, so return a rejected promise
        // and immediately invoke any callbacks.
        $rejection = self::rejectionFor($this->result);
        return $onRejected
            ? $rejection->then(null, $onRejected)
            : $rejection;
    }

    /**
     * Appends a rejection handler callback to the promise, and returns a new
     * promise resolving to the return value of the callback if it is called,
     * or to its original fulfillment value if the promise is instead
     * fulfilled.
     *
     * @param callable $onRejected Invoked when the promise is rejected.
     *
     * @return static
     */
    public function otherwise(callable $onRejected)
    {
        return $this->then(null, $onRejected);
    }

    /**
     * Waits until the promise completes if possible.
     *
     * Pass $unwrap as true to unwrap the result of the promise, either
     * returning the resolved value or throwing the rejected exception.
     *
     * If the promise cannot be waited on, then the promise will be rejected.
     *
     * @param bool $unwrap whether to return the fulfilled value
     *
     * @return mixed
     *
     * @throws LogicException if the promise has no wait function or if the
     *                         promise does not settle after waiting.
     */
    public function wait($unwrap = true)
    {
        $this->waitIfPending();
        if ($this->result instanceof PromiseInterface) {
            return $this->result->wait($unwrap);
        }
        if ($unwrap) {
            if ($this->state === self::FULFILLED) {
                return $this->result;
            }
            // It's rejected so "unwrap" and throw an exception.
            $exception = self::exceptionFor($this->result);
            throw $exception;
        }
    }

    /**
     * Get the state of the promise ("pending", "rejected", or "fulfilled").
     *
     * The three states can be checked against the constants defined on
     * PromiseInterface: PENDING, FULFILLED, and REJECTED.
     *
     * @return string
     */
    public function getState()
    {
        return $this->state;
    }

    /**
     * Cancels the promise if possible.
     *
     * @link https://github.com/promises-aplus/cancellation-spec/issues/7
     *
     * @return void
     */
    public function cancel()
    {
        if ($this->state !== self::PENDING) {
            return;
        }

        $this->waitFn = null;
        $this->waitList = array();

        if ($this->cancelFn) {
            $cancelFn = $this->cancelFn;
            $this->cancelFn = null;
            try {
                $cancelFn();
            } catch (Throwable $e) {
                $this->reject($e);
            } catch (Exception $e) {
                $this->reject($e);
            }
        }

        // Reject the promise only if it wasn't rejected in a then callback.
        /** @psalm-suppress RedundantCondition */
        if ($this->state === self::PENDING) {
            $this->reject(new CancellationException('Promise has been cancelled'));
        }
    }

    /**
     * Resolve the promise with the given value.
     *
     * @param mixed $value resolve value
     *
     * @return void
     *
     * @throws RuntimeException if the promise is already resolved.
     */
    public function resolve($value)
    {
        $this->settle(self::FULFILLED, $value);
    }

    /**
     * Reject the promise with the given reason.
     *
     * @param mixed $reason reject reason
     *
     * @return void
     *
     * @throws RuntimeException if the promise is already resolved.
     */
    public function reject($reason)
    {
        $this->settle(self::REJECTED, $reason);
    }

    /**
     * Resolve/reject the promise
     *
     * @param string $state self::FULFILLED OR self::REJECTED
     * @param mixed  $value resolve value or reject reason
     *
     * @return void
     *
     * @throws LogicException
     */
    private function settle($state, $value)
    {
        if ($state === $this->state && $value === $this->result) {
            // Ignore calls with the same resolution.
            return;
        }
        if ($this->state !== self::PENDING) {
            throw $this->state === $state
                ? new LogicException('The promise is already ' . $state . '.')
                : new LogicException('Cannot change a ' . $this->state . ' promise to ' . $state);
        }
        if ($value === $this) {
            throw new LogicException('Cannot fulfill or reject a promise with itself');
        }

        // Clear out the state of the promise but stash the handlers.
        $handlers = $this->handlers;
        $this->state = $state;
        $this->result = $value;
        $this->handlers = array();
        $this->waitList = array();
        $this->waitFn = null;
        $this->cancelFn = null;

        if (!$handlers) {
            return;
        }

        $this->settleInvokeHandlers($state, $value, $handlers);
    }

    /**
     * Invoke or merge our handlers
     *
     * @param string $state    self::FULFILLED OR self::REJECTED
     * @param mixed  $value    resolve value or reject reason
     * @param array  $handlers list of Promise, onFulfilled, onRejected
     *
     * @return void
     */
    private function settleInvokeHandlers($state, $value, $handlers)
    {
        /*
            could optimise.. if $value instance of self (not extended from)
            then We can just merge our handlers onto the next promise.
            $value->handlers = \array_merge($value->handlers, $handlers);
        */
        $isThenable = \is_object($value) && \method_exists($value, 'then');
        if ($isThenable) {
            // value is some other thenable implementation
            //   invoke our handlers when value is resolved.
            $value->then(
                static function ($value) use ($handlers) {
                    self::invokeHandlers($handlers, 1, $value);
                },
                static function ($reason) use ($handlers) {
                    self::invokeHandlers($handlers, 2, $reason);
                }
            );
            return;
        }

        // not thenable.. resolve the handlers in the task queue.
        $index = $state === self::FULFILLED ? 1 : 2;
        self::queue()->add(static function () use ($handlers, $index, $value) {
            self::invokeHandlers($handlers, $index, $value);
        });
    }

    /**
     * Invoke promise's onFulfilled or onReject callbacks with value
     *
     * @param array $handlers promise, onFulfilled, onRejected
     * @param int   $index    1 (resolve) or 2 (reject).
     * @param mixed $value    Value to pass to the callback.
     *
     * @return void
     */
    private static function invokeHandlers(array $handlers, $index, $value)
    {
        foreach ($handlers as $handler) {
            self::invokeHandler($handler, $index, $value);
        }
    }

    /**
     * Invoke promise's onFulfilled or onReject callback with value
     *
     * @param array $handler promise, onFulfilled, onRejected
     * @param int   $index   1 (resolve) or 2 (reject).
     * @param mixed $value   Value to pass to the callback.
     *
     * @return void
     */
    private static function invokeHandler(array $handler, $index, $value)
    {
        /** @var PromiseInterface */
        $promise = $handler[0];

        // The promise may have been cancelled or resolved before placing this in the queue.
        if (self::isSettled($promise)) {
            return;
        }

        try {
            if (isset($handler[$index])) {
                /*
                    If $callable throws an exception, $handler will be in the exception stack trace.
                    Since $handler contains a reference to the callable itself we get a circular reference.
                    We clear the $handler here to avoid that memory leak.
                */
                $callable = $handler[$index];
                unset($handler);
                $promise->resolve($callable($value));
                return;
            }
            if ($index === 1) {
                // Forward resolution values as-is.
                $promise->resolve($value);
                return;
            }
            // Forward rejections down the chain.
            $promise->reject($value);
        } catch (Throwable $reason) {
            $promise->reject($reason);
        } catch (Exception $reason) {
            $promise->reject($reason);
        }
    }

    /**
     * Invoke the wait function
     *
     * @return void
     */
    private function waitIfPending()
    {
        if ($this->state !== self::PENDING) {
            return;
        }
        if ($this->waitFn) {
            $this->invokeWaitFn();
        } elseif ($this->waitList) {
            $this->invokeWaitList();
        } else {
            // If there's no wait function, then reject the promise.
            $this->reject('Cannot wait on a promise that has no wait function.'
                . ' You must provide a wait function when constructing the promise'
                . ' to be able to wait on it.');
        }

        self::queue()->run();

        /** @psalm-suppress RedundantCondition */
        if ($this->state === self::PENDING) {
            $this->reject('The wait callback did not resolve the promise');
        }
    }

    /**
     * Invoke the wait function
     *
     * @return void
     *
     * @throws Exception
     */
    private function invokeWaitFn()
    {
        $waitFn = $this->waitFn;
        $this->waitFn = null;
        try {
            $waitFn();
        } catch (Exception $e) {
            if ($this->state !== self::PENDING) {
                // The promise was already resolved,
                //   there's a problem in the application.
                throw $e;
            }
            // The promise has not been resolved yet,
            //   reject the promise with the exception.
            $this->reject($e);
        }
    }

    /**
     * Invoke the wait list
     *
     * @return void
     */
    private function invokeWaitList()
    {
        $waitList = $this->waitList;
        $this->waitList = array();
        foreach ($waitList as $result) {
            do {
                $result->waitIfPending();
                $result = $result->result;
            } while ($result instanceof self);

            if ($result instanceof PromiseInterface) {
                $result->wait(false);
            }
        }
    }
}