Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Exception
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
6 / 6
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getXDataFromClassString
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 getClassStringFromFileContents
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 parseParams
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 stringify
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 mapped_implode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * @package pvcErr
5 * @author: Doug Wilbourne (dougwilbourne@gmail.com)
6 */
7
8declare(strict_types=1);
9
10namespace pvc\err\stock;
11
12use PhpParser\Node;
13use PhpParser\NodeTraverser;
14use PhpParser\ParserFactory;
15use pvc\err\PhpParserNodeVisitorClassName;
16use pvc\err\XCodePrefixes;
17use pvc\interfaces\err\XDataInterface;
18use ReflectionClass;
19use ReflectionException;
20use ReflectionMethod;
21use Stringable;
22use Throwable;
23
24/**
25 * Class Exception
26 */
27class Exception extends \Exception
28{
29    protected ?Throwable $previous = null;
30
31    /**
32     * @param mixed ...$allParams
33     * @throws ReflectionException
34     */
35    public function __construct(...$allParams)
36    {
37        /**
38         * initialize some things that we need to create the message and the code.
39         */
40        $myClassString = static::class;
41        $reflected = new ReflectionClass($myClassString);
42
43        /**
44         * @var XDataInterface|null $xData
45         * of course, it should never be null if the library has been tested.......
46         */
47        $xData = $this->getXDataFromClassString($myClassString);
48        if (is_null($xData)) {
49            $msg = 'No exception data file found for exception ' . $myClassString;
50            $code = 0;
51            throw new \Exception($msg, $code);
52        }
53
54        /**
55         * exception code is code prefix concatenated to local code.
56         */
57        $localCode = $xData->getLocalXCode($myClassString);
58        $globalPrefix = XCodePrefixes::getXCodePrefix($reflected->getNamespaceName());
59        $code = (int)($globalPrefix . $localCode);
60
61        /**
62         * get the message template and variables and do the string substitution.
63         */
64        $messageTemplate = $xData->getXMessageTemplate($myClassString);
65        $messageVariables = $xData->getXMessageVariables($messageTemplate);
66
67        /**
68         * parsing the parameters sets $this->previous as a side effect
69         */
70        $messageParams = $this->parseParams($allParams, $messageVariables);
71        $message = strtr($messageTemplate, $messageParams);
72
73        parent::__construct($message, $code, $this->previous);
74    }
75
76    /**
77     * getXDataFromClassString is a clumsy but effective method that tries to find the
78     * XData object for a given (exception) class string. It does this by searching for a php file that, when
79     * reflected, implements XDataInterface.  When it finds it, the class is instantiated and returned.  Returns null
80     * if it does not find it, meaning that there is no XData object in the library directory that contains
81     * the object referred to by the classString argument.  If there is more than one XData file in the directory,
82     * this returns the first one it comes across.
83     *
84     * @param class-string $classString
85     * @return XDataInterface|null
86     * @throws ReflectionException
87     */
88    protected function getXDataFromClassString(string $classString): ?XDataInterface
89    {
90        /**
91         * reflect the classString
92         */
93        $reflected = new ReflectionClass($classString);
94
95        /**
96         * get the filename (including path) from the reflection.  false is only returned from getFileName if the
97         * class is part of php core or defined in an extension, so it should be ok to typehint $fileName.
98         * @var string $fileName
99         */
100        $fileName = $reflected->getFileName();
101
102        /**
103         * get the directory portion of the filename and scan it for files.
104         * @var string $dir
105         */
106        $dir = pathinfo($fileName, PATHINFO_DIRNAME);
107
108        /**
109         * phpstan does know that scandir cannot return false in this case, so typehint the $files variable
110         * @var array<string> $files
111         */
112        $files = scandir($dir);
113        $files = array_diff($files, ['.', '..']);
114
115        /**
116         * iterate through the list of files, trying to reflect each one and test it for XDataInterface
117         */
118        foreach ($files as $file) {
119            $filePath = $dir . DIRECTORY_SEPARATOR . $file;
120
121            if (is_readable($filePath)) {
122                $fileContents = file_get_contents($filePath) ?: '';
123
124                /** @var class-string $className */
125                $className = self::getClassStringFromFileContents($fileContents);
126
127                if ($className) {
128                    /**
129                     * it is possible that $classname might not be reflectable if the namespacing is messed up or
130                     * missing.  Since it won't autoload, just wrap the thing in a try / catch and fall through if
131                     * the reflection fails.
132                     */
133                    try {
134                        $reflected = new ReflectionClass($className);
135                        /**
136                         * if it implements the right interface, return a new instance.
137                         */
138                        if ($reflected->implementsInterface(XDataInterface::class)) {
139                            /** @var XDataInterface $xData */
140                            $xData = new $className();
141                            return $xData;
142                        }
143                    } catch (Throwable) {
144                    }
145                }
146            }
147        }
148        /**
149         * if we got to here, we've iterated through the directory without finding a file that has the right interface.
150         */
151        return null;
152    }
153
154    /**
155     * This method uses nikic's PhpParser to parse each file in the exception library (directory) and
156     * extract the class string or, if the class is not namespaced, the class name.
157     *
158     * This is implemented with a "node visitor".  The PhpParserNodeVisitorClassName object gets the class name
159     * and namespacing within the file.  The other significant feature of the PhpParserNodeVisitorClassName object
160     * is that it stops traversal of the tree (AST) as soon as the class name is obtained.
161     *
162     * @function getClassStringFromFileContents
163     * @param string $fileContents
164     * @return class-string|false
165     */
166    public static function getClassStringFromFileContents(string $fileContents): string|false
167    {
168        /**
169         * create a parser for the version of PHP currently running on the host, then parse the file.
170         *
171         * The result is an array of nodes, which is the AST
172         */
173        $parser = (new ParserFactory())->createForNewestSupportedVersion();
174        /** @var Node[] $nodes */
175        $nodes = $parser->parse($fileContents);
176
177        /**
178         * PhpParser object which traverses the AST.  Add a visitor to the traverser.  This visitor
179         * gets namespace and class name strings and stops traversal of the AST after it finds a class name.
180         */
181        $traverser = new NodeTraverser();
182        $classVisitor = new PhpParserNodeVisitorClassName();
183        $traverser->addVisitor($classVisitor);
184        $traverser->traverse($nodes);
185
186        /**
187         * two parts to the class string: the namespace and the class name.  If there is no classname, then the file
188         * contents did not declare a class and return false.  If $className is not empty, then check for a namespace.
189         * If the namespace is not empty, prepend it to the className.
190         */
191
192        $className = $classVisitor->getClassname();
193        if ($className === '' || $className === '0') {
194            return false;
195        }
196
197        $namespaceName = $classVisitor->getNamespaceName();
198
199        /** @var class-string $classString */
200        $classString = ($namespaceName === '' || $namespaceName === '0')
201            ? $className : $namespaceName.'\\'.$className;
202
203        return $classString;
204    }
205
206    /**
207     * @function parseParams
208     * @param array<mixed> $paramValues
209     * @param array<mixed> $messageVariables
210     * @return array<mixed>
211     */
212    protected function parseParams(array $paramValues, array $messageVariables): array
213    {
214        $reflected = new ReflectionClass($this);
215        /** @var ReflectionMethod $constructor */
216        $constructor = $reflected->getConstructor();
217        $paramNames = $constructor->getParameters();
218        /**
219         * put the parameters into an associative array using the arguments' variable names (which should be the same
220         * as the template names in the message template) as the indices.  For example, if the raw message looks
221         * like 'Index ${index} is greater than ${limit}', then the argument list in this exception's signature
222         * should be $index and $limit.  The array produced by this method would be ['index' => value1, 'limit' =>
223         * value2].  Note that values may need to be converted to strings (via stringify).
224         */
225        $counter = count($messageVariables);
226
227        /**
228         * put the parameters into an associative array using the arguments' variable names (which should be the same
229         * as the template names in the message template) as the indices.  For example, if the raw message looks
230         * like 'Index ${index} is greater than ${limit}', then the argument list in this exception's signature
231         * should be $index and $limit.  The array produced by this method would be ['index' => value1, 'limit' =>
232         * value2].  Note that values may need to be converted to strings (via stringify).
233         */
234        for ($i = 0, $messageParams = []; $i < $counter; $i++) {
235            $templateVariable = '${' . $paramNames[$i]->name . '}';
236            $messageParams[$templateVariable] = $this->stringify($paramValues[$i]);
237        }
238
239        /**
240         * There should be one more argument to process (the previous exception, which could be null).  BUT, php
241         * allows you to call a function with extra arguments and will not really complain. So in order to be
242         * defensive about this, we take the next argument, if any, and test it for null or Throwable and if it fits
243         * the criterion, then we set it up as $previous.  Remaining arguments, if any, are discarded.
244         */
245        $paramValue = $paramValues[$i] ?? null;
246        if (($paramValue instanceof Throwable) || (is_null($paramValue))) {
247            $this->previous = $paramValue;
248        }
249
250        return $messageParams;
251    }
252
253    /**
254     * stringify
255     * handy for converting exception arguments to strings
256     * @param mixed $var
257     * @return string
258     */
259    protected function stringify(mixed $var): string
260    {
261        if (is_object($var)) {
262            if ($var instanceof Stringable) {
263                return $var->__toString();
264            } else {
265                return serialize($var);
266            }
267        }
268
269        if (is_array($var)) {
270            return '['.$this->mapped_implode(', ', $var).']';
271        }
272
273        if (is_bool($var)) {
274            return '{bool (' . ($var ? 'true' : 'false') . ')}';
275        }
276
277        /** @phpstan-ignore argument.type */
278        return strval($var);
279    }
280
281    /**
282     * @param  string  $glue
283     * @param  array<mixed>  $array
284     * @param  string  $symbol
285     *
286     * @return string
287     */
288    protected function mapped_implode(
289        string $glue,
290        array $array,
291        string $symbol = '='
292    ): string {
293        return implode(
294            $glue,
295            array_map(
296                fn($k, $v): string => $k.$symbol.$this->stringify($v),
297                array_keys($array),
298                array_values($array)
299            )
300        );
301    }
302}