Subversion-Projekte lars-tiefland.laravel_shop

Revision

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