Subversion-Projekte lars-tiefland.laravel_shop

Revision

Zur aktuellen Revision | Details | Letzte Änderung | Log anzeigen | RSS feed

Revision Autor Zeilennr. Zeile
148 lars 1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of PHPUnit.
4
 *
5
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace PHPUnit\Util\Annotation;
11
 
12
use const JSON_ERROR_NONE;
13
use const PREG_OFFSET_CAPTURE;
14
use function array_filter;
15
use function array_key_exists;
16
use function array_map;
17
use function array_merge;
18
use function array_pop;
19
use function array_slice;
20
use function array_values;
21
use function count;
22
use function explode;
23
use function file;
24
use function implode;
25
use function is_array;
26
use function is_int;
27
use function json_decode;
28
use function json_last_error;
29
use function json_last_error_msg;
30
use function preg_match;
31
use function preg_match_all;
32
use function preg_replace;
33
use function preg_split;
34
use function realpath;
35
use function rtrim;
36
use function sprintf;
37
use function str_replace;
38
use function strlen;
39
use function strpos;
40
use function strtolower;
41
use function substr;
42
use function trim;
43
use PharIo\Version\VersionConstraintParser;
44
use PHPUnit\Framework\InvalidDataProviderException;
45
use PHPUnit\Framework\SkippedTestError;
46
use PHPUnit\Framework\Warning;
47
use PHPUnit\Util\Exception;
48
use PHPUnit\Util\InvalidDataSetException;
49
use ReflectionClass;
50
use ReflectionException;
51
use ReflectionFunctionAbstract;
52
use ReflectionMethod;
53
use Reflector;
54
use Traversable;
55
 
56
/**
57
 * This is an abstraction around a PHPUnit-specific docBlock,
58
 * allowing us to ask meaningful questions about a specific
59
 * reflection symbol.
60
 *
61
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
62
 */
63
final class DocBlock
64
{
65
    /**
66
     * @todo This constant should be private (it's public because of TestTest::testGetProvidedDataRegEx)
67
     */
68
    public const REGEX_DATA_PROVIDER = '/@dataProvider\s+([a-zA-Z0-9._:-\\\\x7f-\xff]+)/';
69
 
70
    private const REGEX_REQUIRES_VERSION = '/@requires\s+(?P<name>PHP(?:Unit)?)\s+(?P<operator>[<>=!]{0,2})\s*(?P<version>[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m';
71
 
72
    private const REGEX_REQUIRES_VERSION_CONSTRAINT = '/@requires\s+(?P<name>PHP(?:Unit)?)\s+(?P<constraint>[\d\t \-.|~^]+)[ \t]*\r?$/m';
73
 
74
    private const REGEX_REQUIRES_OS = '/@requires\s+(?P<name>OS(?:FAMILY)?)\s+(?P<value>.+?)[ \t]*\r?$/m';
75
 
76
    private const REGEX_REQUIRES_SETTING = '/@requires\s+(?P<name>setting)\s+(?P<setting>([^ ]+?))\s*(?P<value>[\w\.-]+[\w\.]?)?[ \t]*\r?$/m';
77
 
78
    private const REGEX_REQUIRES = '/@requires\s+(?P<name>function|extension)\s+(?P<value>([^\s<>=!]+))\s*(?P<operator>[<>=!]{0,2})\s*(?P<version>[\d\.-]+[\d\.]?)?[ \t]*\r?$/m';
79
 
80
    private const REGEX_TEST_WITH = '/@testWith\s+/';
81
 
82
    /** @var string */
83
    private $docComment;
84
 
85
    /** @var bool */
86
    private $isMethod;
87
 
88
    /** @var array<string, array<int, string>> pre-parsed annotations indexed by name and occurrence index */
89
    private $symbolAnnotations;
90
 
91
    /**
92
     * @var null|array<string, mixed>
93
     *
94
     * @psalm-var null|(array{
95
     *   __OFFSET: array<string, int>&array{__FILE: string},
96
     *   setting?: array<string, string>,
97
     *   extension_versions?: array<string, array{version: string, operator: string}>
98
     * }&array<
99
     *   string,
100
     *   string|array{version: string, operator: string}|array{constraint: string}|array<int|string, string>
101
     * >)
102
     */
103
    private $parsedRequirements;
104
 
105
    /** @var int */
106
    private $startLine;
107
 
108
    /** @var int */
109
    private $endLine;
110
 
111
    /** @var string */
112
    private $fileName;
113
 
114
    /** @var string */
115
    private $name;
116
 
117
    /**
118
     * @var string
119
     *
120
     * @psalm-var class-string
121
     */
122
    private $className;
123
 
124
    public static function ofClass(ReflectionClass $class): self
125
    {
126
        $className = $class->getName();
127
 
128
        return new self(
129
            (string) $class->getDocComment(),
130
            false,
131
            self::extractAnnotationsFromReflector($class),
132
            $class->getStartLine(),
133
            $class->getEndLine(),
134
            $class->getFileName(),
135
            $className,
136
            $className
137
        );
138
    }
139
 
140
    /**
141
     * @psalm-param class-string $classNameInHierarchy
142
     */
143
    public static function ofMethod(ReflectionMethod $method, string $classNameInHierarchy): self
144
    {
145
        return new self(
146
            (string) $method->getDocComment(),
147
            true,
148
            self::extractAnnotationsFromReflector($method),
149
            $method->getStartLine(),
150
            $method->getEndLine(),
151
            $method->getFileName(),
152
            $method->getName(),
153
            $classNameInHierarchy
154
        );
155
    }
156
 
157
    /**
158
     * Note: we do not preserve an instance of the reflection object, since it cannot be safely (de-)serialized.
159
     *
160
     * @param array<string, array<int, string>> $symbolAnnotations
161
     *
162
     * @psalm-param class-string $className
163
     */
164
    private function __construct(string $docComment, bool $isMethod, array $symbolAnnotations, int $startLine, int $endLine, string $fileName, string $name, string $className)
165
    {
166
        $this->docComment        = $docComment;
167
        $this->isMethod          = $isMethod;
168
        $this->symbolAnnotations = $symbolAnnotations;
169
        $this->startLine         = $startLine;
170
        $this->endLine           = $endLine;
171
        $this->fileName          = $fileName;
172
        $this->name              = $name;
173
        $this->className         = $className;
174
    }
175
 
176
    /**
177
     * @psalm-return array{
178
     *   __OFFSET: array<string, int>&array{__FILE: string},
179
     *   setting?: array<string, string>,
180
     *   extension_versions?: array<string, array{version: string, operator: string}>
181
     * }&array<
182
     *   string,
183
     *   string|array{version: string, operator: string}|array{constraint: string}|array<int|string, string>
184
     * >
185
     *
186
     * @throws Warning if the requirements version constraint is not well-formed
187
     */
188
    public function requirements(): array
189
    {
190
        if ($this->parsedRequirements !== null) {
191
            return $this->parsedRequirements;
192
        }
193
 
194
        $offset            = $this->startLine;
195
        $requires          = [];
196
        $recordedSettings  = [];
197
        $extensionVersions = [];
198
        $recordedOffsets   = [
199
            '__FILE' => realpath($this->fileName),
200
        ];
201
 
202
        // Trim docblock markers, split it into lines and rewind offset to start of docblock
203
        $lines = preg_replace(['#^/\*{2}#', '#\*/$#'], '', preg_split('/\r\n|\r|\n/', $this->docComment));
204
        $offset -= count($lines);
205
 
206
        foreach ($lines as $line) {
207
            if (preg_match(self::REGEX_REQUIRES_OS, $line, $matches)) {
208
                $requires[$matches['name']]        = $matches['value'];
209
                $recordedOffsets[$matches['name']] = $offset;
210
            }
211
 
212
            if (preg_match(self::REGEX_REQUIRES_VERSION, $line, $matches)) {
213
                $requires[$matches['name']] = [
214
                    'version'  => $matches['version'],
215
                    'operator' => $matches['operator'],
216
                ];
217
                $recordedOffsets[$matches['name']] = $offset;
218
            }
219
 
220
            if (preg_match(self::REGEX_REQUIRES_VERSION_CONSTRAINT, $line, $matches)) {
221
                if (!empty($requires[$matches['name']])) {
222
                    $offset++;
223
 
224
                    continue;
225
                }
226
 
227
                try {
228
                    $versionConstraintParser = new VersionConstraintParser;
229
 
230
                    $requires[$matches['name'] . '_constraint'] = [
231
                        'constraint' => $versionConstraintParser->parse(trim($matches['constraint'])),
232
                    ];
233
                    $recordedOffsets[$matches['name'] . '_constraint'] = $offset;
234
                } catch (\PharIo\Version\Exception $e) {
235
                    throw new Warning($e->getMessage(), $e->getCode(), $e);
236
                }
237
            }
238
 
239
            if (preg_match(self::REGEX_REQUIRES_SETTING, $line, $matches)) {
240
                $recordedSettings[$matches['setting']]               = $matches['value'];
241
                $recordedOffsets['__SETTING_' . $matches['setting']] = $offset;
242
            }
243
 
244
            if (preg_match(self::REGEX_REQUIRES, $line, $matches)) {
245
                $name = $matches['name'] . 's';
246
 
247
                if (!isset($requires[$name])) {
248
                    $requires[$name] = [];
249
                }
250
 
251
                $requires[$name][]                                           = $matches['value'];
252
                $recordedOffsets[$matches['name'] . '_' . $matches['value']] = $offset;
253
 
254
                if ($name === 'extensions' && !empty($matches['version'])) {
255
                    $extensionVersions[$matches['value']] = [
256
                        'version'  => $matches['version'],
257
                        'operator' => $matches['operator'],
258
                    ];
259
                }
260
            }
261
 
262
            $offset++;
263
        }
264
 
265
        return $this->parsedRequirements = array_merge(
266
            $requires,
267
            ['__OFFSET' => $recordedOffsets],
268
            array_filter([
269
                'setting'            => $recordedSettings,
270
                'extension_versions' => $extensionVersions,
271
            ])
272
        );
273
    }
274
 
275
    /**
276
     * Returns the provided data for a method.
277
     *
278
     * @throws Exception
279
     */
280
    public function getProvidedData(): ?array
281
    {
282
        /** @noinspection SuspiciousBinaryOperationInspection */
283
        $data = $this->getDataFromDataProviderAnnotation($this->docComment) ?? $this->getDataFromTestWithAnnotation($this->docComment);
284
 
285
        if ($data === null) {
286
            return null;
287
        }
288
 
289
        if ($data === []) {
290
            throw new SkippedTestError;
291
        }
292
 
293
        foreach ($data as $key => $value) {
294
            if (!is_array($value)) {
295
                throw new InvalidDataSetException(
296
                    sprintf(
297
                        'Data set %s is invalid.',
298
                        is_int($key) ? '#' . $key : '"' . $key . '"'
299
                    )
300
                );
301
            }
302
        }
303
 
304
        return $data;
305
    }
306
 
307
    /**
308
     * @psalm-return array<string, array{line: int, value: string}>
309
     */
310
    public function getInlineAnnotations(): array
311
    {
312
        $code        = file($this->fileName);
313
        $lineNumber  = $this->startLine;
314
        $startLine   = $this->startLine - 1;
315
        $endLine     = $this->endLine - 1;
316
        $codeLines   = array_slice($code, $startLine, $endLine - $startLine + 1);
317
        $annotations = [];
318
 
319
        foreach ($codeLines as $line) {
320
            if (preg_match('#/\*\*?\s*@(?P<name>[A-Za-z_-]+)(?:[ \t]+(?P<value>.*?))?[ \t]*\r?\*/$#m', $line, $matches)) {
321
                $annotations[strtolower($matches['name'])] = [
322
                    'line'  => $lineNumber,
323
                    'value' => $matches['value'],
324
                ];
325
            }
326
 
327
            $lineNumber++;
328
        }
329
 
330
        return $annotations;
331
    }
332
 
333
    public function symbolAnnotations(): array
334
    {
335
        return $this->symbolAnnotations;
336
    }
337
 
338
    public function isHookToBeExecutedBeforeClass(): bool
339
    {
340
        return $this->isMethod &&
341
            false !== strpos($this->docComment, '@beforeClass');
342
    }
343
 
344
    public function isHookToBeExecutedAfterClass(): bool
345
    {
346
        return $this->isMethod &&
347
            false !== strpos($this->docComment, '@afterClass');
348
    }
349
 
350
    public function isToBeExecutedBeforeTest(): bool
351
    {
352
        return 1 === preg_match('/@before\b/', $this->docComment);
353
    }
354
 
355
    public function isToBeExecutedAfterTest(): bool
356
    {
357
        return 1 === preg_match('/@after\b/', $this->docComment);
358
    }
359
 
360
    public function isToBeExecutedAsPreCondition(): bool
361
    {
362
        return 1 === preg_match('/@preCondition\b/', $this->docComment);
363
    }
364
 
365
    public function isToBeExecutedAsPostCondition(): bool
366
    {
367
        return 1 === preg_match('/@postCondition\b/', $this->docComment);
368
    }
369
 
370
    private function getDataFromDataProviderAnnotation(string $docComment): ?array
371
    {
372
        $methodName = null;
373
        $className  = $this->className;
374
 
375
        if ($this->isMethod) {
376
            $methodName = $this->name;
377
        }
378
 
379
        if (!preg_match_all(self::REGEX_DATA_PROVIDER, $docComment, $matches)) {
380
            return null;
381
        }
382
 
383
        $result = [];
384
 
385
        foreach ($matches[1] as $match) {
386
            $dataProviderMethodNameNamespace = explode('\\', $match);
387
            $leaf                            = explode('::', array_pop($dataProviderMethodNameNamespace));
388
            $dataProviderMethodName          = array_pop($leaf);
389
 
390
            if (empty($dataProviderMethodNameNamespace)) {
391
                $dataProviderMethodNameNamespace = '';
392
            } else {
393
                $dataProviderMethodNameNamespace = implode('\\', $dataProviderMethodNameNamespace) . '\\';
394
            }
395
 
396
            if (empty($leaf)) {
397
                $dataProviderClassName = $className;
398
            } else {
399
                /** @psalm-var class-string $dataProviderClassName */
400
                $dataProviderClassName = $dataProviderMethodNameNamespace . array_pop($leaf);
401
            }
402
 
403
            try {
404
                $dataProviderClass = new ReflectionClass($dataProviderClassName);
405
 
406
                $dataProviderMethod = $dataProviderClass->getMethod(
407
                    $dataProviderMethodName
408
                );
409
                // @codeCoverageIgnoreStart
410
            } catch (ReflectionException $e) {
411
                throw new Exception(
412
                    $e->getMessage(),
413
                    $e->getCode(),
414
                    $e
415
                );
416
                // @codeCoverageIgnoreEnd
417
            }
418
 
419
            if ($dataProviderMethod->isStatic()) {
420
                $object = null;
421
            } else {
422
                $object = $dataProviderClass->newInstance();
423
            }
424
 
425
            if ($dataProviderMethod->getNumberOfParameters() === 0) {
426
                $data = $dataProviderMethod->invoke($object);
427
            } else {
428
                $data = $dataProviderMethod->invoke($object, $methodName);
429
            }
430
 
431
            if ($data instanceof Traversable) {
432
                $origData = $data;
433
                $data     = [];
434
 
435
                foreach ($origData as $key => $value) {
436
                    if (is_int($key)) {
437
                        $data[] = $value;
438
                    } elseif (array_key_exists($key, $data)) {
439
                        throw new InvalidDataProviderException(
440
                            sprintf(
441
                                'The key "%s" has already been defined in the data provider "%s".',
442
                                $key,
443
                                $match
444
                            )
445
                        );
446
                    } else {
447
                        $data[$key] = $value;
448
                    }
449
                }
450
            }
451
 
452
            if (is_array($data)) {
453
                $result = array_merge($result, $data);
454
            }
455
        }
456
 
457
        return $result;
458
    }
459
 
460
    /**
461
     * @throws Exception
462
     */
463
    private function getDataFromTestWithAnnotation(string $docComment): ?array
464
    {
465
        $docComment = $this->cleanUpMultiLineAnnotation($docComment);
466
 
467
        if (!preg_match(self::REGEX_TEST_WITH, $docComment, $matches, PREG_OFFSET_CAPTURE)) {
468
            return null;
469
        }
470
 
471
        $offset            = strlen($matches[0][0]) + $matches[0][1];
472
        $annotationContent = substr($docComment, $offset);
473
        $data              = [];
474
 
475
        foreach (explode("\n", $annotationContent) as $candidateRow) {
476
            $candidateRow = trim($candidateRow);
477
 
478
            if ($candidateRow[0] !== '[') {
479
                break;
480
            }
481
 
482
            $dataSet = json_decode($candidateRow, true);
483
 
484
            if (json_last_error() !== JSON_ERROR_NONE) {
485
                throw new Exception(
486
                    'The data set for the @testWith annotation cannot be parsed: ' . json_last_error_msg()
487
                );
488
            }
489
 
490
            $data[] = $dataSet;
491
        }
492
 
493
        if (!$data) {
494
            throw new Exception('The data set for the @testWith annotation cannot be parsed.');
495
        }
496
 
497
        return $data;
498
    }
499
 
500
    private function cleanUpMultiLineAnnotation(string $docComment): string
501
    {
502
        //removing initial '   * ' for docComment
503
        $docComment = str_replace("\r\n", "\n", $docComment);
504
        $docComment = preg_replace('/' . '\n' . '\s*' . '\*' . '\s?' . '/', "\n", $docComment);
505
        $docComment = (string) substr($docComment, 0, -1);
506
 
507
        return rtrim($docComment, "\n");
508
    }
509
 
510
    /** @return array<string, array<int, string>> */
511
    private static function parseDocBlock(string $docBlock): array
512
    {
513
        // Strip away the docblock header and footer to ease parsing of one line annotations
514
        $docBlock    = (string) substr($docBlock, 3, -2);
515
        $annotations = [];
516
 
517
        if (preg_match_all('/@(?P<name>[A-Za-z_-]+)(?:[ \t]+(?P<value>.*?))?[ \t]*\r?$/m', $docBlock, $matches)) {
518
            $numMatches = count($matches[0]);
519
 
520
            for ($i = 0; $i < $numMatches; $i++) {
521
                $annotations[$matches['name'][$i]][] = (string) $matches['value'][$i];
522
            }
523
        }
524
 
525
        return $annotations;
526
    }
527
 
528
    /** @param ReflectionClass|ReflectionFunctionAbstract $reflector */
529
    private static function extractAnnotationsFromReflector(Reflector $reflector): array
530
    {
531
        $annotations = [];
532
 
533
        if ($reflector instanceof ReflectionClass) {
534
            $annotations = array_merge(
535
                $annotations,
536
                ...array_map(
537
                    static function (ReflectionClass $trait): array
538
                    {
539
                        return self::parseDocBlock((string) $trait->getDocComment());
540
                    },
541
                    array_values($reflector->getTraits())
542
                )
543
            );
544
        }
545
 
546
        return array_merge(
547
            $annotations,
548
            self::parseDocBlock((string) $reflector->getDocComment())
549
        );
550
    }
551
}