without file name parameter can be used only once.'
145 | : "Cannot specify output by -o into file '$file' more then once.",
146 | );
147 | } elseif ($file === '') {
148 | $this->stdoutFormat = $format;
149 | }
150 |
151 | $outputFiles[$file] = true;
152 |
153 | return [$format, $file];
154 | }],
155 | ],
156 | );
157 |
158 | if (isset($_SERVER['argv'])) {
159 | if (($tmp = array_search('-l', $_SERVER['argv'], strict: true))
160 | || ($tmp = array_search('-log', $_SERVER['argv'], strict: true))
161 | || ($tmp = array_search('--log', $_SERVER['argv'], strict: true))
162 | ) {
163 | $_SERVER['argv'][$tmp] = '-o';
164 | $_SERVER['argv'][$tmp + 1] = 'log:' . $_SERVER['argv'][$tmp + 1];
165 | }
166 |
167 | if ($tmp = array_search('--tap', $_SERVER['argv'], strict: true)) {
168 | unset($_SERVER['argv'][$tmp]);
169 | $_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']);
170 | }
171 | }
172 |
173 | $this->options = $cmd->parse();
174 | if ($this->options['--temp'] === null) {
175 | if (($temp = sys_get_temp_dir()) === '') {
176 | echo "Note: System temporary directory is not set.\n";
177 | } elseif (($real = realpath($temp)) === false) {
178 | echo "Note: System temporary directory '$temp' does not exist.\n";
179 | } else {
180 | $this->options['--temp'] = Helpers::prepareTempDir($real);
181 | }
182 | } else {
183 | $this->options['--temp'] = Helpers::prepareTempDir($this->options['--temp']);
184 | }
185 |
186 | return $cmd;
187 | }
188 |
189 |
190 | private function createPhpInterpreter(): void
191 | {
192 | $args = $this->options['-C'] ? [] : ['-n'];
193 | if ($this->options['-c']) {
194 | array_push($args, '-c', $this->options['-c']);
195 | } elseif (!$this->options['--info'] && !$this->options['-C']) {
196 | echo "Note: No php.ini is used.\n";
197 | }
198 |
199 | if (in_array($this->stdoutFormat, ['tap', 'junit'], true)) {
200 | array_push($args, '-d', 'html_errors=off');
201 | }
202 |
203 | foreach ($this->options['-d'] as $item) {
204 | array_push($args, '-d', $item);
205 | }
206 |
207 | $this->interpreter = new PhpInterpreter($this->options['-p'], $args);
208 |
209 | if ($error = $this->interpreter->getStartupError()) {
210 | echo Dumper::color('red', "PHP startup error: $error") . "\n";
211 | }
212 | }
213 |
214 |
215 | private function createRunner(): Runner
216 | {
217 | $runner = new Runner($this->interpreter);
218 | $runner->paths = $this->options['paths'];
219 | $runner->threadCount = max(1, (int) $this->options['-j']);
220 | $runner->stopOnFail = (bool) $this->options['--stop-on-fail'];
221 | $runner->setTempDirectory($this->options['--temp']);
222 |
223 | if ($this->stdoutFormat === null) {
224 | $runner->outputHandlers[] = new Output\ConsolePrinter(
225 | $runner,
226 | (bool) $this->options['-s'],
227 | 'php://output',
228 | (bool) $this->options['--cider'],
229 | );
230 | }
231 |
232 | foreach ($this->options['-o'] as $output) {
233 | [$format, $file] = $output;
234 | match ($format) {
235 | 'console', 'console-lines' => $runner->outputHandlers[] = new Output\ConsolePrinter(
236 | $runner,
237 | (bool) $this->options['-s'],
238 | $file,
239 | (bool) $this->options['--cider'],
240 | $format === 'console-lines',
241 | ),
242 | 'tap' => $runner->outputHandlers[] = new Output\TapPrinter($file),
243 | 'junit' => $runner->outputHandlers[] = new Output\JUnitPrinter($file),
244 | 'log' => $runner->outputHandlers[] = new Output\Logger($runner, $file),
245 | 'none' => null,
246 | default => throw new \LogicException("Undefined output printer '$format'.'"),
247 | };
248 | }
249 |
250 | if ($this->options['--setup']) {
251 | (function () use ($runner): void {
252 | require func_get_arg(0);
253 | })($this->options['--setup']);
254 | }
255 |
256 | return $runner;
257 | }
258 |
259 |
260 | private function prepareCodeCoverage(Runner $runner): string
261 | {
262 | $engines = $this->interpreter->getCodeCoverageEngines();
263 | if (count($engines) < 1) {
264 | throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})");
265 | }
266 |
267 | file_put_contents($this->options['--coverage'], '');
268 | $file = realpath($this->options['--coverage']);
269 |
270 | [$engine, $version] = reset($engines);
271 |
272 | $runner->setEnvironmentVariable(Environment::VariableCoverage, $file);
273 | $runner->setEnvironmentVariable(Environment::VariableCoverageEngine, $engine);
274 |
275 | if ($engine === CodeCoverage\Collector::EngineXdebug && version_compare($version, '3.0.0', '>=')) {
276 | $runner->addPhpIniOption('xdebug.mode', ltrim(ini_get('xdebug.mode') . ',coverage', ','));
277 | }
278 |
279 | if ($engine === CodeCoverage\Collector::EnginePcov && count($this->options['--coverage-src'])) {
280 | $runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src']));
281 | }
282 |
283 | echo "Code coverage by $engine: $file\n";
284 | return $file;
285 | }
286 |
287 |
288 | private function finishCodeCoverage(string $file): void
289 | {
290 | if (!in_array($this->stdoutFormat, ['none', 'tap', 'junit'], true)) {
291 | echo 'Generating code coverage report... ';
292 | }
293 |
294 | if (filesize($file) === 0) {
295 | echo 'failed. Coverage file is empty. Do you call Tester\Environment::setup() in tests?' . "\n";
296 | return;
297 | }
298 |
299 | $generator = pathinfo($file, PATHINFO_EXTENSION) === 'xml'
300 | ? new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src'])
301 | : new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']);
302 | $generator->render($file);
303 | echo round($generator->getCoveredPercent()) . "% covered\n";
304 | }
305 |
306 |
307 | private function watch(Runner $runner): void
308 | {
309 | $prev = [];
310 | $counter = 0;
311 | while (true) {
312 | $state = [];
313 | foreach ($this->options['--watch'] as $directory) {
314 | foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) {
315 | if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') {
316 | $state[(string) $file] = @filemtime((string) $file); // @ file could be deleted in the meantime
317 | }
318 | }
319 | }
320 |
321 | if ($state !== $prev) {
322 | $prev = $state;
323 | try {
324 | $runner->run();
325 | } catch (\ErrorException $e) {
326 | $this->displayException($e);
327 | }
328 |
329 | echo "\n";
330 | $time = time();
331 | }
332 |
333 | $idle = time() - $time;
334 | if ($idle >= 60 * 60) {
335 | $idle = 'long time';
336 | } elseif ($idle >= 60) {
337 | $idle = round($idle / 60) . ' min';
338 | } else {
339 | $idle .= ' sec';
340 | }
341 |
342 | echo 'Watching ' . implode(', ', $this->options['--watch']) . " (idle for $idle) " . str_repeat('.', ++$counter % 5) . " \r";
343 | sleep(2);
344 | }
345 | }
346 |
347 |
348 | private function setupErrors(): void
349 | {
350 | error_reporting(E_ALL);
351 | ini_set('html_errors', '0');
352 |
353 | set_error_handler(function (int $severity, string $message, string $file, int $line) {
354 | if (($severity & error_reporting()) === $severity) {
355 | throw new \ErrorException($message, 0, $severity, $file, $line);
356 | }
357 |
358 | return false;
359 | });
360 |
361 | set_exception_handler(function (\Throwable $e) {
362 | if (!$e instanceof InterruptException) {
363 | $this->displayException($e);
364 | }
365 |
366 | exit(2);
367 | });
368 | }
369 |
370 |
371 | private function displayException(\Throwable $e): void
372 | {
373 | echo "\n";
374 | echo $this->debugMode
375 | ? Dumper::dumpException($e)
376 | : Dumper::color('white/red', 'Error: ' . $e->getMessage());
377 | echo "\n";
378 | }
379 |
380 |
381 | private function installInterruptHandler(): void
382 | {
383 | if (function_exists('pcntl_signal')) {
384 | pcntl_signal(SIGINT, function (): void {
385 | pcntl_signal(SIGINT, SIG_DFL);
386 | throw new InterruptException;
387 | });
388 | pcntl_async_signals(true);
389 |
390 | } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') {
391 | sapi_windows_set_ctrl_handler(function (): void {
392 | throw new InterruptException;
393 | });
394 | }
395 | }
396 | }
397 |
--------------------------------------------------------------------------------
/src/Framework/Dumper.php:
--------------------------------------------------------------------------------
1 | self::$maxLength) {
50 | $var = substr($var, 0, self::$maxLength) . '...';
51 | }
52 |
53 | return self::encodeStringLine($var);
54 |
55 | } elseif (is_array($var)) {
56 | $out = '';
57 | $counter = 0;
58 | foreach ($var as $k => &$v) {
59 | $out .= ($out === '' ? '' : ', ');
60 | if (strlen($out) > self::$maxLength) {
61 | $out .= '...';
62 | break;
63 | }
64 |
65 | $out .= ($k === $counter ? '' : self::toLine($k) . ' => ')
66 | . (is_array($v) && $v ? '[...]' : self::toLine($v));
67 | $counter = is_int($k) ? max($k + 1, $counter) : $counter;
68 | }
69 |
70 | return "[$out]";
71 |
72 | } elseif ($var instanceof \Throwable) {
73 | return 'Exception ' . $var::class . ': ' . ($var->getCode() ? '#' . $var->getCode() . ' ' : '') . $var->getMessage();
74 |
75 | } elseif ($var instanceof Expect) {
76 | return $var->dump();
77 |
78 | } elseif (is_object($var)) {
79 | return self::objectToLine($var);
80 |
81 | } elseif (is_resource($var)) {
82 | return 'resource(' . get_resource_type($var) . ')';
83 |
84 | } else {
85 | return 'unknown type';
86 | }
87 | }
88 |
89 |
90 | /**
91 | * Formats object to line.
92 | */
93 | private static function objectToLine(object $object): string
94 | {
95 | $line = $object::class;
96 | if ($object instanceof \DateTime || $object instanceof \DateTimeInterface) {
97 | $line .= '(' . $object->format('Y-m-d H:i:s O') . ')';
98 | }
99 |
100 | return $line . '(' . self::hash($object) . ')';
101 | }
102 |
103 |
104 | /**
105 | * Dumps variable in PHP format.
106 | */
107 | public static function toPhp(mixed $var): string
108 | {
109 | return self::_toPhp($var);
110 | }
111 |
112 |
113 | /**
114 | * Returns object's stripped hash.
115 | */
116 | private static function hash(object $object): string
117 | {
118 | return '#' . substr(md5(spl_object_hash($object)), 0, 4);
119 | }
120 |
121 |
122 | private static function _toPhp(mixed &$var, array &$list = [], int $level = 0, int &$line = 1): string
123 | {
124 | if (is_float($var)) {
125 | $var = str_replace(',', '.', "$var");
126 | return !str_contains($var, '.') ? $var . '.0' : $var;
127 |
128 | } elseif (is_bool($var)) {
129 | return $var ? 'true' : 'false';
130 |
131 | } elseif ($var === null) {
132 | return 'null';
133 |
134 | } elseif (is_string($var)) {
135 | $res = self::encodeStringPhp($var);
136 | $line += substr_count($res, "\n");
137 | return $res;
138 |
139 | } elseif (is_array($var)) {
140 | $space = str_repeat("\t", $level);
141 |
142 | static $marker;
143 | if ($marker === null) {
144 | $marker = uniqid("\x00", more_entropy: true);
145 | }
146 |
147 | if (empty($var)) {
148 | $out = '';
149 |
150 | } elseif ($level > self::$maxDepth || isset($var[$marker])) {
151 | return '/* Nesting level too deep or recursive dependency */';
152 |
153 | } else {
154 | $out = "\n$space";
155 | $outShort = '';
156 | $var[$marker] = true;
157 | $oldLine = $line;
158 | $line++;
159 | $counter = 0;
160 | foreach ($var as $k => &$v) {
161 | if ($k !== $marker) {
162 | $item = ($k === $counter ? '' : self::_toPhp($k, $list, $level + 1, $line) . ' => ') . self::_toPhp($v, $list, $level + 1, $line);
163 | $counter = is_int($k) ? max($k + 1, $counter) : $counter;
164 | $outShort .= ($outShort === '' ? '' : ', ') . $item;
165 | $out .= "\t$item,\n$space";
166 | $line++;
167 | }
168 | }
169 |
170 | unset($var[$marker]);
171 | if (!str_contains($outShort, "\n") && strlen($outShort) < self::$maxLength) {
172 | $line = $oldLine;
173 | $out = $outShort;
174 | }
175 | }
176 |
177 | return '[' . $out . ']';
178 |
179 | } elseif ($var instanceof \Closure) {
180 | $rc = new \ReflectionFunction($var);
181 | return "/* Closure defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
182 |
183 | } elseif ($var instanceof \UnitEnum) {
184 | return $var::class . '::' . $var->name;
185 |
186 | } elseif (is_object($var)) {
187 | if (($rc = new \ReflectionObject($var))->isAnonymous()) {
188 | return "/* Anonymous class defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
189 | }
190 |
191 | $arr = (array) $var;
192 | $space = str_repeat("\t", $level);
193 | $class = $var::class;
194 | $used = &$list[spl_object_hash($var)];
195 |
196 | if (empty($arr)) {
197 | $out = '';
198 |
199 | } elseif ($used) {
200 | return "/* $class dumped on line $used */";
201 |
202 | } elseif ($level > self::$maxDepth) {
203 | return '/* Nesting level too deep */';
204 |
205 | } else {
206 | $out = "\n";
207 | $used = $line;
208 | $line++;
209 | foreach ($arr as $k => &$v) {
210 | if (isset($k[0]) && $k[0] === "\x00") {
211 | $k = substr($k, strrpos($k, "\x00") + 1);
212 | }
213 |
214 | $out .= "$space\t" . self::_toPhp($k, $list, $level + 1, $line) . ' => ' . self::_toPhp($v, $list, $level + 1, $line) . ",\n";
215 | $line++;
216 | }
217 |
218 | $out .= $space;
219 | }
220 |
221 | $hash = self::hash($var);
222 | return $class === 'stdClass'
223 | ? "(object) /* $hash */ [$out]"
224 | : "$class::__set_state(/* $hash */ [$out])";
225 |
226 | } elseif (is_resource($var)) {
227 | return '/* resource ' . get_resource_type($var) . ' */';
228 |
229 | } else {
230 | return var_export($var, return: true);
231 | }
232 | }
233 |
234 |
235 | private static function encodeStringPhp(string $s): string
236 | {
237 | $special = [
238 | "\r" => '\r',
239 | "\n" => '\n',
240 | "\t" => "\t",
241 | "\e" => '\e',
242 | '\\' => '\\\\',
243 | ];
244 | $utf8 = preg_match('##u', $s);
245 | $escaped = preg_replace_callback(
246 | $utf8 ? '#[\p{C}\\\]#u' : '#[\x00-\x1F\x7F-\xFF\\\]#',
247 | fn($m) => $special[$m[0]] ?? (strlen($m[0]) === 1
248 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . ''
249 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'),
250 | $s,
251 | );
252 | return $s === str_replace('\\\\', '\\', $escaped)
253 | ? "'" . preg_replace('#\'|\\\(?=[\'\\\]|$)#D', '\\\$0', $s) . "'"
254 | : '"' . addcslashes($escaped, '"$') . '"';
255 | }
256 |
257 |
258 | private static function encodeStringLine(string $s): string
259 | {
260 | $special = [
261 | "\r" => "\\r\r",
262 | "\n" => "\\n\n",
263 | "\t" => "\\t\t",
264 | "\e" => '\e',
265 | "'" => "'",
266 | ];
267 | $utf8 = preg_match('##u', $s);
268 | $escaped = preg_replace_callback(
269 | $utf8 ? '#[\p{C}\']#u' : '#[\x00-\x1F\x7F-\xFF\']#',
270 | fn($m) => "\e[22m"
271 | . ($special[$m[0]] ?? (strlen($m[0]) === 1
272 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT)
273 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'))
274 | . "\e[1m",
275 | $s,
276 | );
277 | return "'" . $escaped . "'";
278 | }
279 |
280 |
281 | private static function utf8Ord(string $c): int
282 | {
283 | $ord0 = ord($c[0]);
284 | if ($ord0 < 0x80) {
285 | return $ord0;
286 | } elseif ($ord0 < 0xE0) {
287 | return ($ord0 << 6) + ord($c[1]) - 0x3080;
288 | } elseif ($ord0 < 0xF0) {
289 | return ($ord0 << 12) + (ord($c[1]) << 6) + ord($c[2]) - 0xE2080;
290 | } else {
291 | return ($ord0 << 18) + (ord($c[1]) << 12) + (ord($c[2]) << 6) + ord($c[3]) - 0x3C82080;
292 | }
293 | }
294 |
295 |
296 | public static function dumpException(\Throwable $e): string
297 | {
298 | $trace = $e->getTrace();
299 | array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]);
300 |
301 | $testFile = null;
302 | foreach (array_reverse($trace) as $item) {
303 | if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls
304 | $testFile = $item['file'];
305 | break;
306 | }
307 | }
308 |
309 | if ($e instanceof AssertException) {
310 | $expected = $e->expected;
311 | $actual = $e->actual;
312 | $testFile = $e->outputName
313 | ? dirname($testFile) . '/' . $e->outputName . '.foo'
314 | : $testFile;
315 |
316 | if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength)
317 | || is_object($actual) || is_array($actual) || (is_string($actual) && (strlen($actual) > self::$maxLength || preg_match('#[\x00-\x1F]#', $actual)))
318 | ) {
319 | $args = isset($_SERVER['argv'][1])
320 | ? '.[' . implode(' ', preg_replace(['#^-*([^|]+).*#i', '#[^=a-z0-9. -]+#i'], ['$1', '-'], array_slice($_SERVER['argv'], 1))) . ']'
321 | : '';
322 | $stored[] = self::saveOutput($testFile, $expected, $args . '.expected');
323 | $stored[] = self::saveOutput($testFile, $actual, $args . '.actual');
324 | }
325 |
326 | if ((is_string($actual) && is_string($expected))) {
327 | for ($i = 0; $i < strlen($actual) && isset($expected[$i]) && $actual[$i] === $expected[$i]; $i++);
328 | for (; $i && $i < strlen($actual) && $actual[$i - 1] >= "\x80" && $actual[$i] >= "\x80" && $actual[$i] < "\xC0"; $i--);
329 | $i = max(0, min(
330 | $i - (int) (self::$maxLength / 3), // try to display 1/3 of shorter string
331 | max(strlen($actual), strlen($expected)) - self::$maxLength + 3, // 3 = length of ...
332 | ));
333 | if ($i) {
334 | $expected = substr_replace($expected, '...', 0, $i);
335 | $actual = substr_replace($actual, '...', 0, $i);
336 | }
337 | }
338 |
339 | $message = 'Failed: ' . $e->origMessage;
340 | if (((is_string($actual) && is_string($expected)) || (is_array($actual) && is_array($expected)))
341 | && preg_match('#^(.*)(%\d)(.*)(%\d.*)$#Ds', $message, $m)
342 | ) {
343 | $message = ($delta = strlen($m[1]) - strlen($m[3])) >= 3
344 | ? "$m[1]$m[2]\n" . str_repeat(' ', $delta - 3) . "...$m[3]$m[4]"
345 | : "$m[1]$m[2]$m[3]\n" . str_repeat(' ', strlen($m[1]) - 4) . "... $m[4]";
346 | }
347 |
348 | $message = strtr($message, [
349 | '%1' => self::color('yellow') . self::toLine($actual) . self::color('white'),
350 | '%2' => self::color('yellow') . self::toLine($expected) . self::color('white'),
351 | ]);
352 | } else {
353 | $message = ($e instanceof \ErrorException ? Helpers::errorTypeToString($e->getSeverity()) : $e::class)
354 | . ': ' . preg_replace('#[\x00-\x09\x0B-\x1F]+#', ' ', $e->getMessage());
355 | }
356 |
357 | $s = self::color('white', $message) . "\n\n"
358 | . (isset($stored) ? 'diff ' . Helpers::escapeArg($stored[0]) . ' ' . Helpers::escapeArg($stored[1]) . "\n\n" : '');
359 |
360 | foreach ($trace as $item) {
361 | $item += ['file' => null, 'class' => null, 'type' => null, 'function' => null];
362 | if ($e instanceof AssertException && $item['file'] === __DIR__ . DIRECTORY_SEPARATOR . 'Assert.php') {
363 | continue;
364 | }
365 |
366 | $line = $item['class'] === Assert::class && method_exists($item['class'], $item['function'])
367 | && strpos($tmp = file($item['file'])[$item['line'] - 1], "::$item[function](") ? $tmp : null;
368 |
369 | $s .= 'in '
370 | . ($item['file']
371 | ? (
372 | ($item['file'] === $testFile ? self::color('white') : '')
373 | . implode(
374 | self::$pathSeparator ?? DIRECTORY_SEPARATOR,
375 | array_slice(explode(DIRECTORY_SEPARATOR, $item['file']), -self::$maxPathSegments),
376 | )
377 | . "($item[line])" . self::color('gray') . ' '
378 | )
379 | : '[internal function]'
380 | )
381 | . ($line
382 | ? trim($line)
383 | : $item['class'] . $item['type'] . $item['function'] . ($item['function'] ? '()' : '')
384 | )
385 | . self::color() . "\n";
386 | }
387 |
388 | if ($e->getPrevious()) {
389 | $s .= "\n(previous) " . static::dumpException($e->getPrevious());
390 | }
391 |
392 | return $s;
393 | }
394 |
395 |
396 | /**
397 | * Dumps data to folder 'output'.
398 | */
399 | public static function saveOutput(string $testFile, mixed $content, string $suffix = ''): string
400 | {
401 | $path = self::$dumpDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . $suffix;
402 | if (!preg_match('#/|\w:#A', self::$dumpDir)) {
403 | $path = dirname($testFile) . DIRECTORY_SEPARATOR . $path;
404 | }
405 |
406 | @mkdir(dirname($path)); // @ - directory may already exist
407 | file_put_contents($path, is_string($content) ? $content : (self::toPhp($content) . "\n"));
408 | return $path;
409 | }
410 |
411 |
412 | /**
413 | * Applies color to string.
414 | */
415 | public static function color(string $color = '', ?string $s = null): string
416 | {
417 | $colors = [
418 | 'black' => '0;30', 'gray' => '1;30', 'silver' => '0;37', 'white' => '1;37',
419 | 'navy' => '0;34', 'blue' => '1;34', 'green' => '0;32', 'lime' => '1;32',
420 | 'teal' => '0;36', 'aqua' => '1;36', 'maroon' => '0;31', 'red' => '1;31',
421 | 'purple' => '0;35', 'fuchsia' => '1;35', 'olive' => '0;33', 'yellow' => '1;33',
422 | '' => '0',
423 | ];
424 | $c = explode('/', $color);
425 | return "\e["
426 | . str_replace(';', "m\e[", $colors[$c[0]] . (empty($c[1]) ? '' : ';4' . substr($colors[$c[1]], -1)))
427 | . 'm' . $s . ($s === null ? '' : "\e[0m");
428 | }
429 |
430 |
431 | public static function removeColors(string $s): string
432 | {
433 | return preg_replace('#\e\[[\d;]+m#', '', $s);
434 | }
435 | }
436 |
--------------------------------------------------------------------------------
/src/CodeCoverage/Generators/template.phtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | = $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage
9 |
10 |
212 |
213 |
214 |
215 |
216 | name);
221 |
222 | $currentFile = '';
223 | foreach ($keys as $key) {
224 | $currentFile = $currentFile . ($currentFile !== '' ? DIRECTORY_SEPARATOR : '') . $key;
225 | $arr = &$arr['files'][$key];
226 |
227 | if (!isset($arr['name'])) {
228 | $arr['name'] = $currentFile;
229 | }
230 | $arr['count'] = isset($arr['count']) ? $arr['count'] + 1 : 1;
231 | $arr['coverage'] = isset($arr['coverage']) ? $arr['coverage'] + $info->coverage : $info->coverage;
232 | }
233 | $arr = $value;
234 | }
235 |
236 | $jsonData = [];
237 | $directories = [];
238 | $allLinesCount = 0;
239 | foreach ($files as $id => $info) {
240 | $code = file_get_contents($info->file);
241 | $lineCount = substr_count($code, "\n") + 1;
242 | $digits = ceil(log10($lineCount)) + 1;
243 |
244 | $allLinesCount += $lineCount;
245 |
246 | $currentId = "F{$id}";
247 | assignArrayByPath($directories, $info, $currentId);
248 |
249 | $html = highlight_string($code, true);
250 | if (PHP_VERSION_ID < 80300) { // Normalize to HTML introduced by PHP 8.3
251 | $html = preg_replace(
252 | [
253 | '#^\n#',
255 | '# #',
256 | '#\n\n$#'
257 | ],
258 | [
259 | '',
263 | ],
264 | $html,
265 | );
266 | $html = "$html
";
267 | }
268 |
269 | $data = (array) $info;
270 | $data['digits'] = $digits;
271 | $data['lineCount'] = $lineCount;
272 | $data['content'] = strtr($html, [
273 | '' => "",
274 | '' => '',
276 | ]);
277 | $jsonData[$currentId] = $data;
278 | } ?>
279 |
280 |
281 | = $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage = round($coveredPercent) ?> %
282 | sources have = number_format($allLinesCount) ?> lines of code in = number_format(count($files)) ?> files
283 |
284 |
285 |
289 |
290 |
291 |
292 |
293 |
294 |
295 | |
296 | ↓↑ %
297 | |
298 |
299 |
300 | |
301 |
302 | path↓↑
303 | |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
315 |
316 |
586 |
587 |
588 |
--------------------------------------------------------------------------------
/src/Framework/Assert.php:
--------------------------------------------------------------------------------
1 | '%', // one % character
27 | '%a%' => '[^\r\n]+', // one or more of anything except the end of line characters
28 | '%a\?%' => '[^\r\n]*', // zero or more of anything except the end of line characters
29 | '%A%' => '.+', // one or more of anything including the end of line characters
30 | '%A\?%' => '.*', // zero or more of anything including the end of line characters
31 | '%s%' => '[\t ]+', // one or more white space characters except the end of line characters
32 | '%s\?%' => '[\t ]*', // zero or more white space characters except the end of line characters
33 | '%S%' => '\S+', // one or more of characters except the white space
34 | '%S\?%' => '\S*', // zero or more of characters except the white space
35 | '%c%' => '[^\r\n]', // a single character of any sort (except the end of line)
36 | '%d%' => '[0-9]+', // one or more digits
37 | '%d\?%' => '[0-9]*', // zero or more digits
38 | '%i%' => '[+-]?[0-9]+', // signed integer value
39 | '%f%' => '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', // floating point number
40 | '%h%' => '[0-9a-fA-F]+', // one or more HEX digits
41 | '%w%' => '[0-9a-zA-Z_]+', //one or more alphanumeric characters
42 | '%ds%' => '[\\\/]', // directory separator
43 | '%(\[.+\][+*?{},\d]*)%' => '$1', // range
44 | ];
45 |
46 | /** expand patterns in match() and matchFile() */
47 | public static bool $expandPatterns = true;
48 |
49 | /** @var callable function (AssertException $exception): void */
50 | public static $onFailure;
51 | public static int $counter = 0;
52 |
53 |
54 | /**
55 | * Asserts that two values are equal and have the same type and identity of objects.
56 | */
57 | public static function same(mixed $expected, mixed $actual, ?string $description = null): void
58 | {
59 | self::$counter++;
60 | if ($actual !== $expected) {
61 | self::fail(self::describe('%1 should be %2', $description), $actual, $expected);
62 | }
63 | }
64 |
65 |
66 | /**
67 | * Asserts that two values are not equal or do not have the same type and identity of objects.
68 | */
69 | public static function notSame(mixed $expected, mixed $actual, ?string $description = null): void
70 | {
71 | self::$counter++;
72 | if ($actual === $expected) {
73 | self::fail(self::describe('%1 should not be %2', $description), $actual, $expected);
74 | }
75 | }
76 |
77 |
78 | /**
79 | * Asserts that two values are equal and checks expectations. The identity of objects,
80 | * the order of keys in the arrays and marginally different floats are ignored by default.
81 | */
82 | public static function equal(
83 | mixed $expected,
84 | mixed $actual,
85 | ?string $description = null,
86 | bool $matchOrder = false,
87 | bool $matchIdentity = false,
88 | ): void
89 | {
90 | self::$counter++;
91 | if (!self::isEqual($expected, $actual, $matchOrder, $matchIdentity)) {
92 | self::fail(self::describe('%1 should be equal to %2', $description), $actual, $expected);
93 | }
94 | }
95 |
96 |
97 | /**
98 | * Asserts that two values are not equal and checks expectations. The identity of objects,
99 | * the order of keys in the arrays and marginally different floats are ignored.
100 | */
101 | public static function notEqual(mixed $expected, mixed $actual, ?string $description = null): void
102 | {
103 | self::$counter++;
104 | try {
105 | $res = self::isEqual($expected, $actual, matchOrder: false, matchIdentity: false);
106 | } catch (AssertException $e) {
107 | }
108 |
109 | if (empty($e) && $res) {
110 | self::fail(self::describe('%1 should not be equal to %2', $description), $actual, $expected);
111 | }
112 | }
113 |
114 |
115 | /**
116 | * Asserts that a haystack (string or array) contains an expected needle.
117 | */
118 | public static function contains(mixed $needle, array|string $actual, ?string $description = null): void
119 | {
120 | self::$counter++;
121 | if (is_array($actual)) {
122 | if (!in_array($needle, $actual, true)) {
123 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
124 | }
125 | } elseif (!is_string($needle)) {
126 | self::fail(self::describe('Needle %1 should be string'), $needle);
127 |
128 | } elseif ($needle !== '' && !str_contains($actual, $needle)) {
129 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
130 | }
131 | }
132 |
133 |
134 | /**
135 | * Asserts that a haystack (string or array) does not contain an expected needle.
136 | */
137 | public static function notContains(mixed $needle, array|string $actual, ?string $description = null): void
138 | {
139 | self::$counter++;
140 | if (is_array($actual)) {
141 | if (in_array($needle, $actual, true)) {
142 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
143 | }
144 | } elseif (!is_string($needle)) {
145 | self::fail(self::describe('Needle %1 should be string'), $needle);
146 |
147 | } elseif ($needle === '' || str_contains($actual, $needle)) {
148 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
149 | }
150 | }
151 |
152 |
153 | /**
154 | * Asserts that a haystack has an expected key.
155 | */
156 | public static function hasKey(string|int $key, array $actual, ?string $description = null): void
157 | {
158 | self::$counter++;
159 | if (!array_key_exists($key, $actual)) {
160 | self::fail(self::describe('%1 should contain key %2', $description), $actual, $key);
161 | }
162 | }
163 |
164 |
165 | /**
166 | * Asserts that a haystack doesn't have an expected key.
167 | */
168 | public static function hasNotKey(string|int $key, array $actual, ?string $description = null): void
169 | {
170 | self::$counter++;
171 | if (array_key_exists($key, $actual)) {
172 | self::fail(self::describe('%1 should not contain key %2', $description), $actual, $key);
173 | }
174 | }
175 |
176 |
177 | /**
178 | * Asserts that a value is true.
179 | */
180 | public static function true(mixed $actual, ?string $description = null): void
181 | {
182 | self::$counter++;
183 | if ($actual !== true) {
184 | self::fail(self::describe('%1 should be true', $description), $actual);
185 | }
186 | }
187 |
188 |
189 | /**
190 | * Asserts that a value is false.
191 | */
192 | public static function false(mixed $actual, ?string $description = null): void
193 | {
194 | self::$counter++;
195 | if ($actual !== false) {
196 | self::fail(self::describe('%1 should be false', $description), $actual);
197 | }
198 | }
199 |
200 |
201 | /**
202 | * Asserts that a value is null.
203 | */
204 | public static function null(mixed $actual, ?string $description = null): void
205 | {
206 | self::$counter++;
207 | if ($actual !== null) {
208 | self::fail(self::describe('%1 should be null', $description), $actual);
209 | }
210 | }
211 |
212 |
213 | /**
214 | * Asserts that a value is not null.
215 | */
216 | public static function notNull(mixed $actual, ?string $description = null): void
217 | {
218 | self::$counter++;
219 | if ($actual === null) {
220 | self::fail(self::describe('Value should not be null', $description));
221 | }
222 | }
223 |
224 |
225 | /**
226 | * Asserts that a value is Not a Number.
227 | */
228 | public static function nan(mixed $actual, ?string $description = null): void
229 | {
230 | self::$counter++;
231 | if (!is_float($actual) || !is_nan($actual)) {
232 | self::fail(self::describe('%1 should be NAN', $description), $actual);
233 | }
234 | }
235 |
236 |
237 | /**
238 | * Asserts that a value is truthy.
239 | */
240 | public static function truthy(mixed $actual, ?string $description = null): void
241 | {
242 | self::$counter++;
243 | if (!$actual) {
244 | self::fail(self::describe('%1 should be truthy', $description), $actual);
245 | }
246 | }
247 |
248 |
249 | /**
250 | * Asserts that a value is falsey.
251 | */
252 | public static function falsey(mixed $actual, ?string $description = null): void
253 | {
254 | self::$counter++;
255 | if ($actual) {
256 | self::fail(self::describe('%1 should be falsey', $description), $actual);
257 | }
258 | }
259 |
260 |
261 | /**
262 | * Asserts the number of items in an array or Countable.
263 | */
264 | public static function count(int $count, array|\Countable $value, ?string $description = null): void
265 | {
266 | self::$counter++;
267 | if (count($value) !== $count) {
268 | self::fail(self::describe('Count %1 should be %2', $description), count($value), $count);
269 | }
270 | }
271 |
272 |
273 | /**
274 | * Asserts that a value is of given class, interface or built-in type.
275 | */
276 | public static function type(string|object $type, mixed $value, ?string $description = null): void
277 | {
278 | self::$counter++;
279 | if ($type === 'list') {
280 | if (!is_array($value) || ($value && array_keys($value) !== range(0, count($value) - 1))) {
281 | self::fail(self::describe("%1 should be $type", $description), $value);
282 | }
283 | } elseif (in_array($type, ['array', 'bool', 'callable', 'float',
284 | 'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true)
285 | ) {
286 | if (!("is_$type")($value)) {
287 | self::fail(self::describe(get_debug_type($value) . " should be $type", $description));
288 | }
289 | } elseif (!$value instanceof $type) {
290 | $actual = get_debug_type($value);
291 | $type = is_object($type) ? $type::class : $type;
292 | self::fail(self::describe("$actual should be instance of $type", $description));
293 | }
294 | }
295 |
296 |
297 | /**
298 | * Asserts that a function throws exception of given type and its message matches given pattern.
299 | */
300 | public static function exception(
301 | callable $function,
302 | string $class,
303 | ?string $message = null,
304 | $code = null,
305 | ): ?\Throwable
306 | {
307 | self::$counter++;
308 | $e = null;
309 | try {
310 | $function();
311 | } catch (\Throwable $e) {
312 | }
313 |
314 | if ($e === null) {
315 | self::fail("$class was expected, but none was thrown");
316 |
317 | } elseif (!$e instanceof $class) {
318 | self::fail("$class was expected but got " . $e::class . ($e->getMessage() ? " ({$e->getMessage()})" : ''), null, null, $e);
319 |
320 | } elseif ($message && !self::isMatching($message, $e->getMessage())) {
321 | self::fail("$class with a message matching %2 was expected but got %1", $e->getMessage(), $message, $e);
322 |
323 | } elseif ($code !== null && $e->getCode() !== $code) {
324 | self::fail("$class with a code %2 was expected but got %1", $e->getCode(), $code, $e);
325 | }
326 |
327 | return $e;
328 | }
329 |
330 |
331 | /**
332 | * Asserts that a function throws exception of given type and its message matches given pattern. Alias for exception().
333 | */
334 | public static function throws(
335 | callable $function,
336 | string $class,
337 | ?string $message = null,
338 | mixed $code = null,
339 | ): ?\Throwable
340 | {
341 | return self::exception($function, $class, $message, $code);
342 | }
343 |
344 |
345 | /**
346 | * Asserts that a function generates one or more PHP errors or throws exceptions.
347 | * @throws \Exception
348 | */
349 | public static function error(
350 | callable $function,
351 | int|string|array $expectedType,
352 | ?string $expectedMessage = null,
353 | ): ?\Throwable
354 | {
355 | if (is_string($expectedType) && !preg_match('#^E_[A-Z_]+$#D', $expectedType)) {
356 | return static::exception($function, $expectedType, $expectedMessage);
357 | }
358 |
359 | self::$counter++;
360 | $expected = is_array($expectedType) ? $expectedType : [[$expectedType, $expectedMessage]];
361 | foreach ($expected as &$item) {
362 | $item = ((array) $item) + [null, null];
363 | $expectedType = $item[0];
364 | if (is_int($expectedType)) {
365 | $item[2] = Helpers::errorTypeToString($expectedType);
366 | } elseif (is_string($expectedType)) {
367 | $item[0] = constant($item[2] = $expectedType);
368 | } else {
369 | throw new \Exception('Error type must be E_* constant.');
370 | }
371 | }
372 |
373 | set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$expected) {
374 | if (($severity & error_reporting()) !== $severity) {
375 | return;
376 | }
377 |
378 | $errorStr = Helpers::errorTypeToString($severity) . ($message ? " ($message)" : '');
379 | [$expectedType, $expectedMessage, $expectedTypeStr] = array_shift($expected);
380 | if ($expectedType === null) {
381 | self::fail("Generated more errors than expected: $errorStr was generated in file $file on line $line");
382 |
383 | } elseif ($severity !== $expectedType) {
384 | self::fail("$expectedTypeStr was expected, but $errorStr was generated in file $file on line $line");
385 |
386 | } elseif ($expectedMessage && !self::isMatching($expectedMessage, $message)) {
387 | self::fail("$expectedTypeStr with a message matching %2 was expected but got %1", $message, $expectedMessage);
388 | }
389 | });
390 |
391 | reset($expected);
392 | try {
393 | $function();
394 | restore_error_handler();
395 | } catch (\Throwable $e) {
396 | restore_error_handler();
397 | throw $e;
398 | }
399 |
400 | if ($expected) {
401 | self::fail('Error was expected, but was not generated');
402 | }
403 |
404 | return null;
405 | }
406 |
407 |
408 | /**
409 | * Asserts that a function does not generate PHP errors and does not throw exceptions.
410 | */
411 | public static function noError(callable $function): void
412 | {
413 | if (($count = func_num_args()) > 1) {
414 | throw new \Exception(__METHOD__ . "() expects 1 parameter, $count given.");
415 | }
416 |
417 | self::error($function, []);
418 | }
419 |
420 |
421 | /**
422 | * Asserts that a string matches a given pattern.
423 | * %a% one or more of anything except the end of line characters
424 | * %a?% zero or more of anything except the end of line characters
425 | * %A% one or more of anything including the end of line characters
426 | * %A?% zero or more of anything including the end of line characters
427 | * %s% one or more white space characters except the end of line characters
428 | * %s?% zero or more white space characters except the end of line characters
429 | * %S% one or more of characters except the white space
430 | * %S?% zero or more of characters except the white space
431 | * %c% a single character of any sort (except the end of line)
432 | * %d% one or more digits
433 | * %d?% zero or more digits
434 | * %i% signed integer value
435 | * %f% floating point number
436 | * %h% one or more HEX digits
437 | * @param string $pattern mask|regexp; only delimiters ~ and # are supported for regexp
438 | */
439 | public static function match(string $pattern, string $actual, ?string $description = null): void
440 | {
441 | self::$counter++;
442 | if (!self::isMatching($pattern, $actual)) {
443 | if (self::$expandPatterns) {
444 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
445 | }
446 |
447 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
448 | }
449 | }
450 |
451 |
452 | public static function notMatch(string $pattern, string $actual, ?string $description = null): void
453 | {
454 | self::$counter++;
455 | if (self::isMatching($pattern, $actual)) {
456 | if (self::$expandPatterns) {
457 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
458 | }
459 |
460 | self::fail(self::describe('%1 should not match %2', $description), $actual, $pattern);
461 | }
462 | }
463 |
464 |
465 | /**
466 | * Asserts that a string matches a given pattern stored in file.
467 | */
468 | public static function matchFile(string $file, string $actual, ?string $description = null): void
469 | {
470 | self::$counter++;
471 | $pattern = @file_get_contents($file); // @ is escalated to exception
472 | if ($pattern === false) {
473 | throw new \Exception("Unable to read file '$file'.");
474 |
475 | } elseif (!self::isMatching($pattern, $actual)) {
476 | if (self::$expandPatterns) {
477 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
478 | }
479 |
480 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern, null, basename($file));
481 | }
482 | }
483 |
484 |
485 | /**
486 | * Assertion that fails.
487 | */
488 | public static function fail(
489 | string $message,
490 | $actual = null,
491 | $expected = null,
492 | ?\Throwable $previous = null,
493 | ?string $outputName = null,
494 | ): void
495 | {
496 | $e = new AssertException($message, $expected, $actual, $previous);
497 | $e->outputName = $outputName;
498 | if (self::$onFailure) {
499 | (self::$onFailure)($e);
500 | } else {
501 | throw $e;
502 | }
503 | }
504 |
505 |
506 | private static function describe(string $reason, ?string $description = null): string
507 | {
508 | return ($description ? $description . ': ' : '') . $reason;
509 | }
510 |
511 |
512 | /**
513 | * Executes function that can access private and protected members of given object via $this.
514 | */
515 | public static function with(object|string $objectOrClass, \Closure $closure): mixed
516 | {
517 | return $closure->bindTo(is_object($objectOrClass) ? $objectOrClass : null, $objectOrClass)();
518 | }
519 |
520 |
521 | /********************* helpers ****************d*g**/
522 |
523 |
524 | /**
525 | * Compares using mask.
526 | * @internal
527 | */
528 | public static function isMatching(string $pattern, string $actual, bool $strict = false): bool
529 | {
530 | $old = ini_set('pcre.backtrack_limit', '10000000');
531 |
532 | if (!self::isPcre($pattern)) {
533 | $utf8 = preg_match('#\x80-\x{10FFFF}]#u', $pattern) ? 'u' : '';
534 | $suffix = ($strict ? '$#DsU' : '\s*$#sU') . $utf8;
535 | $patterns = static::$patterns + [
536 | '[.\\\+*?[^$(){|\#]' => '\$0', // preg quoting
537 | '\x00' => '\x00',
538 | '[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim
539 | ];
540 | $pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) {
541 | foreach ($patterns as $re => $replacement) {
542 | $s = preg_replace("#^$re$#D", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count);
543 | if ($count) {
544 | return $s;
545 | }
546 | }
547 | }, rtrim($pattern, " \t\n\r")) . $suffix;
548 | }
549 |
550 | $res = preg_match($pattern, (string) $actual);
551 | ini_set('pcre.backtrack_limit', $old);
552 | if ($res === false || preg_last_error()) {
553 | throw new \Exception('Error while executing regular expression. (' . preg_last_error_msg() . ')');
554 | }
555 |
556 | return (bool) $res;
557 | }
558 |
559 |
560 | /**
561 | * @internal
562 | */
563 | public static function expandMatchingPatterns(string $pattern, string $actual): array
564 | {
565 | if (self::isPcre($pattern)) {
566 | return [$pattern, $actual];
567 | }
568 |
569 | $parts = preg_split('#(%)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE);
570 | for ($i = count($parts); $i >= 0; $i--) {
571 | $patternX = implode('', array_slice($parts, 0, $i));
572 | $patternY = "$patternX%A?%";
573 | if (self::isMatching($patternY, $actual)) {
574 | $patternZ = implode('', array_slice($parts, $i));
575 | break;
576 | }
577 | }
578 |
579 | foreach (['%A%', '%A?%'] as $greedyPattern) {
580 | if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) {
581 | $patternX = substr($patternX, 0, -strlen($greedyPattern));
582 | $patternY = "$patternX%A?%";
583 | $patternZ = $greedyPattern . $patternZ;
584 | break;
585 | }
586 | }
587 |
588 | $low = 0;
589 | $high = strlen($actual);
590 | while ($low <= $high) {
591 | $mid = ($low + $high) >> 1;
592 | if (self::isMatching($patternY, substr($actual, 0, $mid))) {
593 | $high = $mid - 1;
594 | } else {
595 | $low = $mid + 1;
596 | }
597 | }
598 |
599 | $low = $high + 2;
600 | $high = strlen($actual);
601 | while ($low <= $high) {
602 | $mid = ($low + $high) >> 1;
603 | if (!self::isMatching($patternX, substr($actual, 0, $mid), strict: true)) {
604 | $high = $mid - 1;
605 | } else {
606 | $low = $mid + 1;
607 | }
608 | }
609 |
610 | $actualX = substr($actual, 0, $high);
611 | $actualZ = substr($actual, $high);
612 |
613 | return [
614 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $patternZ)),
615 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $actualZ)),
616 | ];
617 | }
618 |
619 |
620 | /**
621 | * Compares two structures and checks expectations. The identity of objects, the order of keys
622 | * in the arrays and marginally different floats are ignored.
623 | */
624 | private static function isEqual(
625 | mixed $expected,
626 | mixed $actual,
627 | bool $matchOrder,
628 | bool $matchIdentity,
629 | int $level = 0,
630 | ?\SplObjectStorage $objects = null,
631 | ): bool
632 | {
633 | switch (true) {
634 | case $level > 10:
635 | throw new \Exception('Nesting level too deep or recursive dependency.');
636 |
637 | case $expected instanceof Expect:
638 | $expected($actual);
639 | return true;
640 |
641 | case is_float($expected) && is_float($actual) && is_finite($expected) && is_finite($actual):
642 | $diff = abs($expected - $actual);
643 | return ($diff < self::Epsilon) || ($diff / max(abs($expected), abs($actual)) < self::Epsilon);
644 |
645 | case !$matchIdentity && is_object($expected) && is_object($actual) && $expected::class === $actual::class:
646 | $objects = $objects ? clone $objects : new \SplObjectStorage;
647 | if (isset($objects[$expected])) {
648 | return $objects[$expected] === $actual;
649 | } elseif ($expected === $actual) {
650 | return true;
651 | }
652 |
653 | $objects[$expected] = $actual;
654 | $objects[$actual] = $expected;
655 | $expected = (array) $expected;
656 | $actual = (array) $actual;
657 | // break omitted
658 |
659 | case is_array($expected) && is_array($actual):
660 | if ($matchOrder) {
661 | reset($expected);
662 | reset($actual);
663 | } else {
664 | ksort($expected, SORT_STRING);
665 | ksort($actual, SORT_STRING);
666 | }
667 |
668 | if (array_keys($expected) !== array_keys($actual)) {
669 | return false;
670 | }
671 |
672 | foreach ($expected as $value) {
673 | if (!self::isEqual($value, current($actual), $matchOrder, $matchIdentity, $level + 1, $objects)) {
674 | return false;
675 | }
676 |
677 | next($actual);
678 | }
679 |
680 | return true;
681 |
682 | default:
683 | return $expected === $actual;
684 | }
685 | }
686 |
687 |
688 | private static function isPcre(string $pattern): bool
689 | {
690 | return (bool) preg_match('/^([~#]).+(\1)[imsxUu]*$/Ds', $pattern);
691 | }
692 | }
693 |
--------------------------------------------------------------------------------