Lines 93.75% 30 / 32
Functions and Methods 88.88% 8 / 9
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
StewartException 93.75% 30 / 32 88.88% 8 / 9 21.11 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 createForReason 100.00% 1 / 1 100.00% 1 / 1 1
 createForReasonWithAppendedText 100.00% 7 / 7 100.00% 1 / 1 3
 restoreFromWire 100.00% 1 / 1 100.00% 1 / 1 1
 findContextString 0.00% 0 / 2 0.00% 0 / 1 6
 requireNamedContext 100.00% 4 / 4 100.00% 1 / 1 3
 renderMessage 100.00% 5 / 5 100.00% 1 / 1 1
 renderPlaceholder 100.00% 8 / 8 100.00% 1 / 1 4
 renderScalar 100.00% 3 / 3 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Contracts\Exception;
6
7use LogicException;
8use RuntimeException;
9use Throwable;
10
11/**
12 * @phpstan-type ExceptionContext array<string, scalar|null|list<scalar|null>>
13 *
14 * @template-covariant TReason of ExceptionReason
15 */
16abstract class StewartException extends RuntimeException
17{
18    private const string CAUSE_PLACEHOLDER = 'cause';
19
20    /**
21     * @param TReason $reason
22     * @param ExceptionContext|null $context
23     */
24    final protected function __construct(
25        string $message,
26        public readonly ExceptionReason $reason,
27        public readonly ?array $context = null,
28        ?Throwable $previous = null,
29    ) {
30        parent::__construct($message, 0, $previous);
31    }
32
33    /**
34     * @template TRaisedReason of ExceptionReason
35     *
36     * @param TRaisedReason $exceptionReason
37     * @param scalar|null|list<scalar|null> ...$context
38     * @return static<TRaisedReason>
39     */
40    final public static function createForReason(
41        ExceptionReason $exceptionReason,
42        ?Throwable $previous = null,
43        string|int|float|bool|array|null ...$context,
44    ): static {
45        return self::createForReasonWithAppendedText($exceptionReason, self::requireNamedContext($context), null, $previous);
46    }
47
48    /**
49     * @template TRaisedReason of ExceptionReason
50     *
51     * @param TRaisedReason $reason
52     * @param ExceptionContext $context
53     * @return static<TRaisedReason>
54     */
55    final protected static function createForReasonWithAppendedText(
56        ExceptionReason $reason,
57        array $context,
58        ?string $appendedText,
59        ?Throwable $previous = null,
60    ): static {
61        $message = self::renderMessage($reason, $context, $previous);
62
63        return new static(
64            $appendedText === null ? $message : $message . ' ' . $appendedText,
65            $reason,
66            $context === [] ? null : $context,
67            $previous,
68        );
69    }
70
71    /**
72     * @template TRestoredReason of ExceptionReason
73     *
74     * @param TRestoredReason $reason
75     * @param ExceptionContext|null $context
76     * @return static<TRestoredReason>
77     */
78    final public static function restoreFromWire(ExceptionReason $reason, string $message, ?array $context): static
79    {
80        return new static($message, $reason, $context);
81    }
82
83    protected function findContextString(string $key): ?string
84    {
85        $value = $this->context[$key] ?? null;
86
87        return \is_string($value) ? $value : null;
88    }
89
90    /**
91     * @param array<array-key, scalar|null|list<scalar|null>> $context
92     * @return ExceptionContext
93     */
94    private static function requireNamedContext(array $context): array
95    {
96        $named = [];
97
98        foreach ($context as $key => $value) {
99            $named[\is_string($key) ? $key : throw new LogicException('Pass the exception context as named arguments.')] = $value;
100        }
101
102        return $named;
103    }
104
105    /** @param ExceptionContext $context */
106    private static function renderMessage(ExceptionReason $reason, array $context, ?Throwable $previous): string
107    {
108        return (string) preg_replace_callback(
109            '/\{(\w+)\}/',
110            static fn(array $placeholder): string => self::renderPlaceholder($placeholder[1], $context, $previous),
111            $reason->messageTemplate(),
112        );
113    }
114
115    /** @param ExceptionContext $context */
116    private static function renderPlaceholder(string $name, array $context, ?Throwable $previous): string
117    {
118        if ($name === self::CAUSE_PLACEHOLDER) {
119            return $previous?->getMessage() ?? throw new LogicException('{cause} needs a previous exception.');
120        }
121
122        if (!\array_key_exists($name, $context)) {
123            throw new LogicException(\sprintf('The message template names {%s}, which the context lacks.', $name));
124        }
125
126        $value = $context[$name];
127
128        return \is_array($value)
129            ? implode(', ', array_map(self::renderScalar(...), $value))
130            : self::renderScalar($value);
131    }
132
133    private static function renderScalar(string|int|float|bool|null $value): string
134    {
135        return match (true) {
136            $value === null => 'none',
137            \is_bool($value) => $value ? 'true' : 'false',
138            default => (string) $value,
139        };
140    }
141}