Completed
Push — master ( 8a6821...f9deab )
by Andreas
04:47
created

DocParser::parse()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 7
nc 2
nop 2
dl 0
loc 13
rs 10
c 1
b 0
f 0
1
<?php
2
/*
3
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
4
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
5
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
6
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
7
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
8
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
9
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
10
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
11
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
12
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
13
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
14
 *
15
 * This software consists of voluntary contributions made by many individuals
16
 * and is licensed under the MIT license. For more information, see
17
 * <http://www.doctrine-project.org>.
18
 */
19
20
namespace Doctrine\Common\Annotations;
21
22
use Doctrine\Common\Annotations\Annotation\Attribute;
23
use ReflectionClass;
24
use Doctrine\Common\Annotations\Annotation\Enum;
25
use Doctrine\Common\Annotations\Annotation\Target;
26
use Doctrine\Common\Annotations\Annotation\Attributes;
27
28
/**
29
 * A parser for docblock annotations.
30
 *
31
 * It is strongly discouraged to change the default annotation parsing process.
32
 *
33
 * @author Benjamin Eberlei <[email protected]>
34
 * @author Guilherme Blanco <[email protected]>
35
 * @author Jonathan Wage <[email protected]>
36
 * @author Roman Borschel <[email protected]>
37
 * @author Johannes M. Schmitt <[email protected]>
38
 * @author Fabio B. Silva <[email protected]>
39
 */
40
final class DocParser
41
{
42
    /**
43
     * An array of all valid tokens for a class name.
44
     *
45
     * @var array
46
     */
47
    private static $classIdentifiers = [
48
        DocLexer::T_IDENTIFIER,
49
        DocLexer::T_TRUE,
50
        DocLexer::T_FALSE,
51
        DocLexer::T_NULL
52
    ];
53
54
    /**
55
     * The lexer.
56
     *
57
     * @var \Doctrine\Common\Annotations\DocLexer
58
     */
59
    private $lexer;
60
61
    /**
62
     * Current target context.
63
     *
64
     * @var integer
65
     */
66
    private $target;
67
68
    /**
69
     * Doc parser used to collect annotation target.
70
     *
71
     * @var \Doctrine\Common\Annotations\DocParser
72
     */
73
    private static $metadataParser;
74
75
    /**
76
     * Flag to control if the current annotation is nested or not.
77
     *
78
     * @var boolean
79
     */
80
    private $isNestedAnnotation = false;
81
82
    /**
83
     * Hashmap containing all use-statements that are to be used when parsing
84
     * the given doc block.
85
     *
86
     * @var array
87
     */
88
    private $imports = [];
89
90
    /**
91
     * This hashmap is used internally to cache results of class_exists()
92
     * look-ups.
93
     *
94
     * @var array
95
     */
96
    private $classExists = [];
97
98
    /**
99
     * Whether annotations that have not been imported should be ignored.
100
     *
101
     * @var boolean
102
     */
103
    private $ignoreNotImportedAnnotations = false;
104
105
    /**
106
     * An array of default namespaces if operating in simple mode.
107
     *
108
     * @var string[]
109
     */
110
    private $namespaces = [];
111
112
    /**
113
     * A list with annotations that are not causing exceptions when not resolved to an annotation class.
114
     *
115
     * The names must be the raw names as used in the class, not the fully qualified
116
     * class names.
117
     *
118
     * @var bool[] indexed by annotation name
119
     */
120
    private $ignoredAnnotationNames = [];
121
122
    /**
123
     * A list with annotations in namespaced format
124
     * that are not causing exceptions when not resolved to an annotation class.
125
     *
126
     * @var bool[] indexed by namespace name
127
     */
128
    private $ignoredAnnotationNamespaces = [];
129
130
    /**
131
     * @var string
132
     */
133
    private $context = '';
134
135
    /**
136
     * Hash-map for caching annotation metadata.
137
     *
138
     * @var array
139
     */
140
    private static $annotationMetadata = [
141
        'Doctrine\Common\Annotations\Annotation\Target' => [
142
            'is_annotation'    => true,
143
            'has_constructor'  => true,
144
            'properties'       => [],
145
            'targets_literal'  => 'ANNOTATION_CLASS',
146
            'targets'          => Target::TARGET_CLASS,
147
            'default_property' => 'value',
148
            'attribute_types'  => [
149
                'value'  => [
150
                    'required'  => false,
151
                    'type'      =>'array',
152
                    'array_type'=>'string',
153
                    'value'     =>'array<string>'
154
                ]
155
             ],
156
        ],
157
        'Doctrine\Common\Annotations\Annotation\Attribute' => [
158
            'is_annotation'    => true,
159
            'has_constructor'  => false,
160
            'targets_literal'  => 'ANNOTATION_ANNOTATION',
161
            'targets'          => Target::TARGET_ANNOTATION,
162
            'default_property' => 'name',
163
            'properties'       => [
164
                'name'      => 'name',
165
                'type'      => 'type',
166
                'required'  => 'required'
167
            ],
168
            'attribute_types'  => [
169
                'value'  => [
170
                    'required'  => true,
171
                    'type'      =>'string',
172
                    'value'     =>'string'
173
                ],
174
                'type'  => [
175
                    'required'  =>true,
176
                    'type'      =>'string',
177
                    'value'     =>'string'
178
                ],
179
                'required'  => [
180
                    'required'  =>false,
181
                    'type'      =>'boolean',
182
                    'value'     =>'boolean'
183
                ]
184
             ],
185
        ],
186
        'Doctrine\Common\Annotations\Annotation\Attributes' => [
187
            'is_annotation'    => true,
188
            'has_constructor'  => false,
189
            'targets_literal'  => 'ANNOTATION_CLASS',
190
            'targets'          => Target::TARGET_CLASS,
191
            'default_property' => 'value',
192
            'properties'       => [
193
                'value' => 'value'
194
            ],
195
            'attribute_types'  => [
196
                'value' => [
197
                    'type'      =>'array',
198
                    'required'  =>true,
199
                    'array_type'=>'Doctrine\Common\Annotations\Annotation\Attribute',
200
                    'value'     =>'array<Doctrine\Common\Annotations\Annotation\Attribute>'
201
                ]
202
             ],
203
        ],
204
        'Doctrine\Common\Annotations\Annotation\Enum' => [
205
            'is_annotation'    => true,
206
            'has_constructor'  => true,
207
            'targets_literal'  => 'ANNOTATION_PROPERTY',
208
            'targets'          => Target::TARGET_PROPERTY,
209
            'default_property' => 'value',
210
            'properties'       => [
211
                'value' => 'value'
212
            ],
213
            'attribute_types'  => [
214
                'value' => [
215
                    'type'      => 'array',
216
                    'required'  => true,
217
                ],
218
                'literal' => [
219
                    'type'      => 'array',
220
                    'required'  => false,
221
                ],
222
             ],
223
        ],
224
    ];
225
226
    /**
227
     * Hash-map for handle types declaration.
228
     *
229
     * @var array
230
     */
231
    private static $typeMap = [
232
        'float'     => 'double',
233
        'bool'      => 'boolean',
234
        // allow uppercase Boolean in honor of George Boole
235
        'Boolean'   => 'boolean',
236
        'int'       => 'integer',
237
    ];
238
239
    /**
240
     * Constructs a new DocParser.
241
     */
242
    public function __construct()
243
    {
244
        $this->lexer = new DocLexer;
245
    }
246
247
    /**
248
     * Sets the annotation names that are ignored during the parsing process.
249
     *
250
     * The names are supposed to be the raw names as used in the class, not the
251
     * fully qualified class names.
252
     *
253
     * @param bool[] $names indexed by annotation name
254
     *
255
     * @return void
256
     */
257
    public function setIgnoredAnnotationNames(array $names)
258
    {
259
        $this->ignoredAnnotationNames = $names;
260
    }
261
262
    /**
263
     * Sets the annotation namespaces that are ignored during the parsing process.
264
     *
265
     * @param bool[] $ignoredAnnotationNamespaces indexed by annotation namespace name
266
     *
267
     * @return void
268
     */
269
    public function setIgnoredAnnotationNamespaces($ignoredAnnotationNamespaces)
270
    {
271
        $this->ignoredAnnotationNamespaces = $ignoredAnnotationNamespaces;
272
    }
273
274
    /**
275
     * Sets ignore on not-imported annotations.
276
     *
277
     * @param boolean $bool
278
     *
279
     * @return void
280
     */
281
    public function setIgnoreNotImportedAnnotations($bool)
282
    {
283
        $this->ignoreNotImportedAnnotations = (boolean) $bool;
284
    }
285
286
    /**
287
     * Sets the default namespaces.
288
     *
289
     * @param string $namespace
290
     *
291
     * @return void
292
     *
293
     * @throws \RuntimeException
294
     */
295
    public function addNamespace($namespace)
296
    {
297
        if ($this->imports) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->imports of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
298
            throw new \RuntimeException('You must either use addNamespace(), or setImports(), but not both.');
299
        }
300
301
        $this->namespaces[] = $namespace;
302
    }
303
304
    /**
305
     * Sets the imports.
306
     *
307
     * @param array $imports
308
     *
309
     * @return void
310
     *
311
     * @throws \RuntimeException
312
     */
313
    public function setImports(array $imports)
314
    {
315
        if ($this->namespaces) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->namespaces of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
316
            throw new \RuntimeException('You must either use addNamespace(), or setImports(), but not both.');
317
        }
318
319
        $this->imports = $imports;
320
    }
321
322
    /**
323
     * Sets current target context as bitmask.
324
     *
325
     * @param integer $target
326
     *
327
     * @return void
328
     */
329
    public function setTarget($target)
330
    {
331
        $this->target = $target;
332
    }
333
334
    /**
335
     * Parses the given docblock string for annotations.
336
     *
337
     * @param string $input   The docblock string to parse.
338
     * @param string $context The parsing context.
339
     *
340
     * @return array Array of annotations. If no annotations are found, an empty array is returned.
341
     */
342
    public function parse($input, $context = '')
343
    {
344
        $pos = $this->findInitialTokenPosition($input);
345
        if ($pos === null) {
346
            return [];
347
        }
348
349
        $this->context = $context;
350
351
        $this->lexer->setInput(trim(substr($input, $pos), '* /'));
352
        $this->lexer->moveNext();
353
354
        return $this->Annotations();
355
    }
356
357
    /**
358
     * Finds the first valid annotation
359
     *
360
     * @param string $input The docblock string to parse
361
     *
362
     * @return int|null
363
     */
364
    private function findInitialTokenPosition($input)
365
    {
366
        $pos = 0;
367
368
        // search for first valid annotation
369
        while (($pos = strpos($input, '@', $pos)) !== false) {
370
            $preceding = substr($input, $pos - 1, 1);
371
372
            // if the @ is preceded by a space, a tab or * it is valid
373
            if ($pos === 0 || $preceding === ' ' || $preceding === '*' || $preceding === "\t") {
374
                return $pos;
375
            }
376
377
            $pos++;
378
        }
379
380
        return null;
381
    }
382
383
    /**
384
     * Attempts to match the given token with the current lookahead token.
385
     * If they match, updates the lookahead token; otherwise raises a syntax error.
386
     *
387
     * @param integer $token Type of token.
388
     *
389
     * @return boolean True if tokens match; false otherwise.
390
     */
391
    private function match($token)
392
    {
393
        if ( ! $this->lexer->isNextToken($token) ) {
394
            $this->syntaxError($this->lexer->getLiteral($token));
395
        }
396
397
        return $this->lexer->moveNext();
398
    }
399
400
    /**
401
     * Attempts to match the current lookahead token with any of the given tokens.
402
     *
403
     * If any of them matches, this method updates the lookahead token; otherwise
404
     * a syntax error is raised.
405
     *
406
     * @param array $tokens
407
     *
408
     * @return boolean
409
     */
410
    private function matchAny(array $tokens)
411
    {
412
        if ( ! $this->lexer->isNextTokenAny($tokens)) {
413
            $this->syntaxError(implode(' or ', array_map([$this->lexer, 'getLiteral'], $tokens)));
414
        }
415
416
        return $this->lexer->moveNext();
417
    }
418
419
    /**
420
     * Generates a new syntax error.
421
     *
422
     * @param string     $expected Expected string.
423
     * @param array|null $token    Optional token.
424
     *
425
     * @return void
426
     *
427
     * @throws AnnotationException
428
     */
429
    private function syntaxError($expected, $token = null)
430
    {
431
        if ($token === null) {
432
            $token = $this->lexer->lookahead;
433
        }
434
435
        $message  = sprintf('Expected %s, got ', $expected);
436
        $message .= ($this->lexer->lookahead === null)
437
            ? 'end of string'
438
            : sprintf("'%s' at position %s", $token['value'], $token['position']);
439
440
        if (strlen($this->context)) {
441
            $message .= ' in ' . $this->context;
442
        }
443
444
        $message .= '.';
445
446
        throw AnnotationException::syntaxError($message);
447
    }
448
449
    /**
450
     * Attempts to check if a class exists or not. This never goes through the PHP autoloading mechanism
451
     * but uses the {@link AnnotationRegistry} to load classes.
452
     *
453
     * @param string $fqcn
454
     *
455
     * @return boolean
456
     */
457
    private function classExists($fqcn)
458
    {
459
        if (isset($this->classExists[$fqcn])) {
460
            return $this->classExists[$fqcn];
461
        }
462
463
        // first check if the class already exists, maybe loaded through another AnnotationReader
464
        if (class_exists($fqcn, false)) {
465
            return $this->classExists[$fqcn] = true;
466
        }
467
468
        // final check, does this class exist?
469
        return $this->classExists[$fqcn] = AnnotationRegistry::loadAnnotationClass($fqcn);
470
    }
471
472
    /**
473
     * Collects parsing metadata for a given annotation class
474
     *
475
     * @param string $name The annotation name
476
     *
477
     * @return void
478
     */
479
    private function collectAnnotationMetadata($name)
480
    {
481
        if (self::$metadataParser === null) {
482
            self::$metadataParser = new self();
483
484
            self::$metadataParser->setIgnoreNotImportedAnnotations(true);
485
            self::$metadataParser->setIgnoredAnnotationNames($this->ignoredAnnotationNames);
486
            self::$metadataParser->setImports([
487
                'enum'          => 'Doctrine\Common\Annotations\Annotation\Enum',
488
                'target'        => 'Doctrine\Common\Annotations\Annotation\Target',
489
                'attribute'     => 'Doctrine\Common\Annotations\Annotation\Attribute',
490
                'attributes'    => 'Doctrine\Common\Annotations\Annotation\Attributes'
491
            ]);
492
493
            AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Enum.php');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...egistry::registerFile() has been deprecated: this method is deprecated and will be removed in doctrine/annotations 2.0 autoloading should be deferred to the globally registered autoloader by then. For now, use @example AnnotationRegistry::registerLoader('class_exists') ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

493
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Enum.php');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
494
            AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Target.php');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...egistry::registerFile() has been deprecated: this method is deprecated and will be removed in doctrine/annotations 2.0 autoloading should be deferred to the globally registered autoloader by then. For now, use @example AnnotationRegistry::registerLoader('class_exists') ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

494
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Target.php');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
495
            AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Attribute.php');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...egistry::registerFile() has been deprecated: this method is deprecated and will be removed in doctrine/annotations 2.0 autoloading should be deferred to the globally registered autoloader by then. For now, use @example AnnotationRegistry::registerLoader('class_exists') ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

495
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Attribute.php');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
496
            AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Attributes.php');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...egistry::registerFile() has been deprecated: this method is deprecated and will be removed in doctrine/annotations 2.0 autoloading should be deferred to the globally registered autoloader by then. For now, use @example AnnotationRegistry::registerLoader('class_exists') ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

496
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerFile(__DIR__ . '/Annotation/Attributes.php');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
497
        }
498
499
        $class      = new \ReflectionClass($name);
500
        $docComment = $class->getDocComment();
501
502
        // Sets default values for annotation metadata
503
        $metadata = [
504
            'default_property' => null,
505
            'has_constructor'  => (null !== $constructor = $class->getConstructor()) && $constructor->getNumberOfParameters() > 0,
506
            'properties'       => [],
507
            'property_types'   => [],
508
            'attribute_types'  => [],
509
            'targets_literal'  => null,
510
            'targets'          => Target::TARGET_ALL,
511
            'is_annotation'    => false !== strpos($docComment, '@Annotation'),
512
        ];
513
514
        // verify that the class is really meant to be an annotation
515
        if ($metadata['is_annotation']) {
516
            self::$metadataParser->setTarget(Target::TARGET_CLASS);
517
518
            foreach (self::$metadataParser->parse($docComment, 'class @' . $name) as $annotation) {
519
                if ($annotation instanceof Target) {
520
                    $metadata['targets']         = $annotation->targets;
521
                    $metadata['targets_literal'] = $annotation->literal;
522
523
                    continue;
524
                }
525
526
                if ($annotation instanceof Attributes) {
527
                    foreach ($annotation->value as $attribute) {
528
                        $this->collectAttributeTypeMetadata($metadata, $attribute);
529
                    }
530
                }
531
            }
532
533
            // if not has a constructor will inject values into public properties
534
            if (false === $metadata['has_constructor']) {
535
                // collect all public properties
536
                foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
537
                    $metadata['properties'][$property->name] = $property->name;
538
539
                    if (false === ($propertyComment = $property->getDocComment())) {
540
                        continue;
541
                    }
542
543
                    $attribute = new Attribute();
544
545
                    $attribute->required = (false !== strpos($propertyComment, '@Required'));
0 ignored issues
show
Bug introduced by
It seems like $propertyComment can also be of type true; however, parameter $haystack of strpos() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

545
                    $attribute->required = (false !== strpos(/** @scrutinizer ignore-type */ $propertyComment, '@Required'));
Loading history...
546
                    $attribute->name     = $property->name;
547
                    $attribute->type     = (false !== strpos($propertyComment, '@var') && preg_match('/@var\s+([^\s]+)/',$propertyComment, $matches))
0 ignored issues
show
Bug introduced by
It seems like $propertyComment can also be of type true; however, parameter $subject of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

547
                    $attribute->type     = (false !== strpos($propertyComment, '@var') && preg_match('/@var\s+([^\s]+)/',/** @scrutinizer ignore-type */ $propertyComment, $matches))
Loading history...
548
                        ? $matches[1]
549
                        : 'mixed';
550
551
                    $this->collectAttributeTypeMetadata($metadata, $attribute);
552
553
                    // checks if the property has @Enum
554
                    if (false !== strpos($propertyComment, '@Enum')) {
555
                        $context = 'property ' . $class->name . "::\$" . $property->name;
556
557
                        self::$metadataParser->setTarget(Target::TARGET_PROPERTY);
558
559
                        foreach (self::$metadataParser->parse($propertyComment, $context) as $annotation) {
0 ignored issues
show
Bug introduced by
It seems like $propertyComment can also be of type true; however, parameter $input of Doctrine\Common\Annotations\DocParser::parse() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

559
                        foreach (self::$metadataParser->parse(/** @scrutinizer ignore-type */ $propertyComment, $context) as $annotation) {
Loading history...
560
                            if ( ! $annotation instanceof Enum) {
561
                                continue;
562
                            }
563
564
                            $metadata['enum'][$property->name]['value']   = $annotation->value;
565
                            $metadata['enum'][$property->name]['literal'] = ( ! empty($annotation->literal))
566
                                ? $annotation->literal
567
                                : $annotation->value;
568
                        }
569
                    }
570
                }
571
572
                // choose the first property as default property
573
                $metadata['default_property'] = reset($metadata['properties']);
574
            }
575
        }
576
577
        self::$annotationMetadata[$name] = $metadata;
578
    }
579
580
    /**
581
     * Collects parsing metadata for a given attribute.
582
     *
583
     * @param array     $metadata
584
     * @param Attribute $attribute
585
     *
586
     * @return void
587
     */
588
    private function collectAttributeTypeMetadata(&$metadata, Attribute $attribute)
589
    {
590
        // handle internal type declaration
591
        $type = self::$typeMap[$attribute->type] ?? $attribute->type;
592
593
        // handle the case if the property type is mixed
594
        if ('mixed' === $type) {
595
            return;
596
        }
597
598
        // Evaluate type
599
        switch (true) {
600
            // Checks if the property has array<type>
601
            case (false !== $pos = strpos($type, '<')):
602
                $arrayType  = substr($type, $pos + 1, -1);
603
                $type       = 'array';
604
605
                if (isset(self::$typeMap[$arrayType])) {
606
                    $arrayType = self::$typeMap[$arrayType];
607
                }
608
609
                $metadata['attribute_types'][$attribute->name]['array_type'] = $arrayType;
610
                break;
611
612
            // Checks if the property has type[]
613
            case (false !== $pos = strrpos($type, '[')):
614
                $arrayType  = substr($type, 0, $pos);
615
                $type       = 'array';
616
617
                if (isset(self::$typeMap[$arrayType])) {
618
                    $arrayType = self::$typeMap[$arrayType];
619
                }
620
621
                $metadata['attribute_types'][$attribute->name]['array_type'] = $arrayType;
622
                break;
623
        }
624
625
        $metadata['attribute_types'][$attribute->name]['type']     = $type;
626
        $metadata['attribute_types'][$attribute->name]['value']    = $attribute->type;
627
        $metadata['attribute_types'][$attribute->name]['required'] = $attribute->required;
628
    }
629
630
    /**
631
     * Annotations ::= Annotation {[ "*" ]* [Annotation]}*
632
     *
633
     * @return array
634
     */
635
    private function Annotations()
636
    {
637
        $annotations = [];
638
639
        while (null !== $this->lexer->lookahead) {
640
            if (DocLexer::T_AT !== $this->lexer->lookahead['type']) {
641
                $this->lexer->moveNext();
642
                continue;
643
            }
644
645
            // make sure the @ is preceded by non-catchable pattern
646
            if (null !== $this->lexer->token && $this->lexer->lookahead['position'] === $this->lexer->token['position'] + strlen($this->lexer->token['value'])) {
647
                $this->lexer->moveNext();
648
                continue;
649
            }
650
651
            // make sure the @ is followed by either a namespace separator, or
652
            // an identifier token
653
            if ((null === $peek = $this->lexer->glimpse())
654
                || (DocLexer::T_NAMESPACE_SEPARATOR !== $peek['type'] && !in_array($peek['type'], self::$classIdentifiers, true))
655
                || $peek['position'] !== $this->lexer->lookahead['position'] + 1) {
656
                $this->lexer->moveNext();
657
                continue;
658
            }
659
660
            $this->isNestedAnnotation = false;
661
            if (false !== $annot = $this->Annotation()) {
662
                $annotations[] = $annot;
663
            }
664
        }
665
666
        return $annotations;
667
    }
668
669
    /**
670
     * Annotation     ::= "@" AnnotationName MethodCall
671
     * AnnotationName ::= QualifiedName | SimpleName
672
     * QualifiedName  ::= NameSpacePart "\" {NameSpacePart "\"}* SimpleName
673
     * NameSpacePart  ::= identifier | null | false | true
674
     * SimpleName     ::= identifier | null | false | true
675
     *
676
     * @return mixed False if it is not a valid annotation.
677
     *
678
     * @throws AnnotationException
679
     */
680
    private function Annotation()
681
    {
682
        $this->match(DocLexer::T_AT);
683
684
        // check if we have an annotation
685
        $name = $this->Identifier();
686
687
        if ($this->lexer->isNextToken(DocLexer::T_MINUS)
688
            && $this->lexer->nextTokenIsAdjacent()
689
        ) {
690
            // Annotations with dashes, such as "@foo-" or "@foo-bar", are to be discarded
691
            return false;
692
        }
693
694
        // only process names which are not fully qualified, yet
695
        // fully qualified names must start with a \
696
        $originalName = $name;
697
698
        if ('\\' !== $name[0]) {
699
            $pos = strpos($name, '\\');
700
            $alias = (false === $pos)? $name : substr($name, 0, $pos);
701
            $found = false;
702
            $loweredAlias = strtolower($alias);
703
704
            if ($this->namespaces) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->namespaces of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
705
                foreach ($this->namespaces as $namespace) {
706
                    if ($this->classExists($namespace.'\\'.$name)) {
707
                        $name = $namespace.'\\'.$name;
708
                        $found = true;
709
                        break;
710
                    }
711
                }
712
            } elseif (isset($this->imports[$loweredAlias])) {
713
                $namespace = ltrim($this->imports[$loweredAlias], '\\');
714
                $name = (false !== $pos)
715
                    ? $namespace . substr($name, $pos)
716
                    : $namespace;
717
                $found = $this->classExists($name);
718
            } elseif ( ! isset($this->ignoredAnnotationNames[$name])
719
                && isset($this->imports['__NAMESPACE__'])
720
                && $this->classExists($this->imports['__NAMESPACE__'] . '\\' . $name)
721
            ) {
722
                $name  = $this->imports['__NAMESPACE__'].'\\'.$name;
723
                $found = true;
724
            } elseif (! isset($this->ignoredAnnotationNames[$name]) && $this->classExists($name)) {
725
                $found = true;
726
            }
727
728
            if ( ! $found) {
729
                if ($this->isIgnoredAnnotation($name)) {
730
                    return false;
731
                }
732
733
                throw AnnotationException::semanticalError(sprintf('The annotation "@%s" in %s was never imported. Did you maybe forget to add a "use" statement for this annotation?', $name, $this->context));
734
            }
735
        }
736
737
        $name = ltrim($name,'\\');
738
739
        if ( ! $this->classExists($name)) {
740
            throw AnnotationException::semanticalError(sprintf('The annotation "@%s" in %s does not exist, or could not be auto-loaded.', $name, $this->context));
741
        }
742
743
        // at this point, $name contains the fully qualified class name of the
744
        // annotation, and it is also guaranteed that this class exists, and
745
        // that it is loaded
746
747
748
        // collects the metadata annotation only if there is not yet
749
        if ( ! isset(self::$annotationMetadata[$name])) {
750
            $this->collectAnnotationMetadata($name);
751
        }
752
753
        // verify that the class is really meant to be an annotation and not just any ordinary class
754
        if (self::$annotationMetadata[$name]['is_annotation'] === false) {
755
            if ($this->ignoreNotImportedAnnotations || isset($this->ignoredAnnotationNames[$originalName])) {
756
                return false;
757
            }
758
759
            throw AnnotationException::semanticalError(sprintf('The class "%s" is not annotated with @Annotation. Are you sure this class can be used as annotation? If so, then you need to add @Annotation to the _class_ doc comment of "%s". If it is indeed no annotation, then you need to add @IgnoreAnnotation("%s") to the _class_ doc comment of %s.', $name, $name, $originalName, $this->context));
760
        }
761
762
        //if target is nested annotation
763
        $target = $this->isNestedAnnotation ? Target::TARGET_ANNOTATION : $this->target;
764
765
        // Next will be nested
766
        $this->isNestedAnnotation = true;
767
768
        //if annotation does not support current target
769
        if (0 === (self::$annotationMetadata[$name]['targets'] & $target) && $target) {
770
            throw AnnotationException::semanticalError(
771
                sprintf('Annotation @%s is not allowed to be declared on %s. You may only use this annotation on these code elements: %s.',
772
                     $originalName, $this->context, self::$annotationMetadata[$name]['targets_literal'])
773
            );
774
        }
775
776
        $values = $this->MethodCall();
777
778
        if (isset(self::$annotationMetadata[$name]['enum'])) {
779
            // checks all declared attributes
780
            foreach (self::$annotationMetadata[$name]['enum'] as $property => $enum) {
781
                // checks if the attribute is a valid enumerator
782
                if (isset($values[$property]) && ! in_array($values[$property], $enum['value'])) {
783
                    throw AnnotationException::enumeratorError($property, $name, $this->context, $enum['literal'], $values[$property]);
784
                }
785
            }
786
        }
787
788
        // checks all declared attributes
789
        foreach (self::$annotationMetadata[$name]['attribute_types'] as $property => $type) {
790
            if ($property === self::$annotationMetadata[$name]['default_property']
791
                && !isset($values[$property]) && isset($values['value'])) {
792
                $property = 'value';
793
            }
794
795
            // handle a not given attribute or null value
796
            if (!isset($values[$property])) {
797
                if ($type['required']) {
798
                    throw AnnotationException::requiredError($property, $originalName, $this->context, 'a(n) '.$type['value']);
799
                }
800
801
                continue;
802
            }
803
804
            if ($type['type'] === 'array') {
805
                // handle the case of a single value
806
                if ( ! is_array($values[$property])) {
807
                    $values[$property] = [$values[$property]];
808
                }
809
810
                // checks if the attribute has array type declaration, such as "array<string>"
811
                if (isset($type['array_type'])) {
812
                    foreach ($values[$property] as $item) {
813
                        if (gettype($item) !== $type['array_type'] && !$item instanceof $type['array_type']) {
814
                            throw AnnotationException::attributeTypeError($property, $originalName, $this->context, 'either a(n) '.$type['array_type'].', or an array of '.$type['array_type'].'s', $item);
815
                        }
816
                    }
817
                }
818
            } elseif (gettype($values[$property]) !== $type['type'] && !$values[$property] instanceof $type['type']) {
819
                throw AnnotationException::attributeTypeError($property, $originalName, $this->context, 'a(n) '.$type['value'], $values[$property]);
820
            }
821
        }
822
823
        // check if the annotation expects values via the constructor,
824
        // or directly injected into public properties
825
        if (self::$annotationMetadata[$name]['has_constructor'] === true) {
826
            return new $name($values);
827
        }
828
829
        $instance = new $name();
830
831
        foreach ($values as $property => $value) {
832
            if (!isset(self::$annotationMetadata[$name]['properties'][$property])) {
833
                if ('value' !== $property) {
834
                    throw AnnotationException::creationError(sprintf('The annotation @%s declared on %s does not have a property named "%s". Available properties: %s', $originalName, $this->context, $property, implode(', ', self::$annotationMetadata[$name]['properties'])));
835
                }
836
837
                // handle the case if the property has no annotations
838
                if ( ! $property = self::$annotationMetadata[$name]['default_property']) {
839
                    throw AnnotationException::creationError(sprintf('The annotation @%s declared on %s does not accept any values, but got %s.', $originalName, $this->context, json_encode($values)));
840
                }
841
            }
842
843
            $instance->{$property} = $value;
844
        }
845
846
        return $instance;
847
    }
848
849
    /**
850
     * MethodCall ::= ["(" [Values] ")"]
851
     *
852
     * @return array
853
     */
854
    private function MethodCall()
855
    {
856
        $values = [];
857
858
        if ( ! $this->lexer->isNextToken(DocLexer::T_OPEN_PARENTHESIS)) {
859
            return $values;
860
        }
861
862
        $this->match(DocLexer::T_OPEN_PARENTHESIS);
863
864
        if ( ! $this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) {
865
            $values = $this->Values();
866
        }
867
868
        $this->match(DocLexer::T_CLOSE_PARENTHESIS);
869
870
        return $values;
871
    }
872
873
    /**
874
     * Values ::= Array | Value {"," Value}* [","]
875
     *
876
     * @return array
877
     */
878
    private function Values()
879
    {
880
        $values = [$this->Value()];
881
882
        while ($this->lexer->isNextToken(DocLexer::T_COMMA)) {
883
            $this->match(DocLexer::T_COMMA);
884
885
            if ($this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) {
886
                break;
887
            }
888
889
            $token = $this->lexer->lookahead;
890
            $value = $this->Value();
891
892
            if ( ! is_object($value) && ! is_array($value)) {
893
                $this->syntaxError('Value', $token);
894
            }
895
896
            $values[] = $value;
897
        }
898
899
        foreach ($values as $k => $value) {
900
            if (is_object($value) && $value instanceof \stdClass) {
901
                $values[$value->name] = $value->value;
902
            } else if ( ! isset($values['value'])){
903
                $values['value'] = $value;
904
            } else {
905
                if ( ! is_array($values['value'])) {
906
                    $values['value'] = [$values['value']];
907
                }
908
909
                $values['value'][] = $value;
910
            }
911
912
            unset($values[$k]);
913
        }
914
915
        return $values;
916
    }
917
918
    /**
919
     * Constant ::= integer | string | float | boolean
920
     *
921
     * @return mixed
922
     *
923
     * @throws AnnotationException
924
     */
925
    private function Constant()
926
    {
927
        $identifier = $this->Identifier();
928
929
        if ( ! defined($identifier) && false !== strpos($identifier, '::') && '\\' !== $identifier[0]) {
930
            list($className, $const) = explode('::', $identifier);
931
932
            $pos = strpos($className, '\\');
933
            $alias = (false === $pos) ? $className : substr($className, 0, $pos);
934
            $found = false;
935
            $loweredAlias = strtolower($alias);
936
937
            switch (true) {
938
                case !empty ($this->namespaces):
939
                    foreach ($this->namespaces as $ns) {
940
                        if (class_exists($ns.'\\'.$className) || interface_exists($ns.'\\'.$className)) {
941
                             $className = $ns.'\\'.$className;
942
                             $found = true;
943
                             break;
944
                        }
945
                    }
946
                    break;
947
948
                case isset($this->imports[$loweredAlias]):
949
                    $found     = true;
950
                    $className = (false !== $pos)
951
                        ? $this->imports[$loweredAlias] . substr($className, $pos)
952
                        : $this->imports[$loweredAlias];
953
                    break;
954
955
                default:
956
                    if(isset($this->imports['__NAMESPACE__'])) {
957
                        $ns = $this->imports['__NAMESPACE__'];
958
959
                        if (class_exists($ns.'\\'.$className) || interface_exists($ns.'\\'.$className)) {
960
                            $className = $ns.'\\'.$className;
961
                            $found = true;
962
                        }
963
                    }
964
                    break;
965
            }
966
967
            if ($found) {
968
                 $identifier = $className . '::' . $const;
969
            }
970
        }
971
972
        /**
973
         * Checks if identifier ends with ::class and remove the leading backslash if it exists.
974
         */
975
        if ($this->identifierEndsWithClassConstant($identifier) && ! $this->identifierStartsWithBackslash($identifier)) {
976
            return substr($identifier, 0, $this->getClassConstantPositionInIdentifier($identifier));
977
        }
978
        if ($this->identifierEndsWithClassConstant($identifier) && $this->identifierStartsWithBackslash($identifier)) {
979
            return substr($identifier, 1, $this->getClassConstantPositionInIdentifier($identifier) - 1);
980
        }
981
982
        if (!defined($identifier)) {
983
            throw AnnotationException::semanticalErrorConstants($identifier, $this->context);
984
        }
985
986
        return constant($identifier);
987
    }
988
989
    private function identifierStartsWithBackslash(string $identifier) : bool
990
    {
991
        return '\\' === $identifier[0];
992
    }
993
994
    private function identifierEndsWithClassConstant(string $identifier) : bool
995
    {
996
        return $this->getClassConstantPositionInIdentifier($identifier) === strlen($identifier) - strlen('::class');
997
    }
998
999
    /**
1000
     * @return int|false
1001
     */
1002
    private function getClassConstantPositionInIdentifier(string $identifier)
1003
    {
1004
        return stripos($identifier, '::class');
1005
    }
1006
1007
    /**
1008
     * Identifier ::= string
1009
     *
1010
     * @return string
1011
     */
1012
    private function Identifier()
1013
    {
1014
        // check if we have an annotation
1015
        if ( ! $this->lexer->isNextTokenAny(self::$classIdentifiers)) {
1016
            $this->syntaxError('namespace separator or identifier');
1017
        }
1018
1019
        $this->lexer->moveNext();
1020
1021
        $className = $this->lexer->token['value'];
1022
1023
        while (
1024
            null !== $this->lexer->lookahead &&
1025
            $this->lexer->lookahead['position'] === ($this->lexer->token['position'] + strlen($this->lexer->token['value'])) &&
1026
            $this->lexer->isNextToken(DocLexer::T_NAMESPACE_SEPARATOR)
1027
        ) {
1028
            $this->match(DocLexer::T_NAMESPACE_SEPARATOR);
1029
            $this->matchAny(self::$classIdentifiers);
1030
1031
            $className .= '\\' . $this->lexer->token['value'];
1032
        }
1033
1034
        return $className;
1035
    }
1036
1037
    /**
1038
     * Value ::= PlainValue | FieldAssignment
1039
     *
1040
     * @return mixed
1041
     */
1042
    private function Value()
1043
    {
1044
        $peek = $this->lexer->glimpse();
1045
1046
        if (DocLexer::T_EQUALS === $peek['type']) {
1047
            return $this->FieldAssignment();
1048
        }
1049
1050
        return $this->PlainValue();
1051
    }
1052
1053
    /**
1054
     * PlainValue ::= integer | string | float | boolean | Array | Annotation
1055
     *
1056
     * @return mixed
1057
     */
1058
    private function PlainValue()
1059
    {
1060
        if ($this->lexer->isNextToken(DocLexer::T_OPEN_CURLY_BRACES)) {
1061
            return $this->Arrayx();
1062
        }
1063
1064
        if ($this->lexer->isNextToken(DocLexer::T_AT)) {
1065
            return $this->Annotation();
1066
        }
1067
1068
        if ($this->lexer->isNextToken(DocLexer::T_IDENTIFIER)) {
1069
            return $this->Constant();
1070
        }
1071
1072
        switch ($this->lexer->lookahead['type']) {
1073
            case DocLexer::T_STRING:
1074
                $this->match(DocLexer::T_STRING);
1075
                return $this->lexer->token['value'];
1076
1077
            case DocLexer::T_INTEGER:
1078
                $this->match(DocLexer::T_INTEGER);
1079
                return (int)$this->lexer->token['value'];
1080
1081
            case DocLexer::T_FLOAT:
1082
                $this->match(DocLexer::T_FLOAT);
1083
                return (float)$this->lexer->token['value'];
1084
1085
            case DocLexer::T_TRUE:
1086
                $this->match(DocLexer::T_TRUE);
1087
                return true;
1088
1089
            case DocLexer::T_FALSE:
1090
                $this->match(DocLexer::T_FALSE);
1091
                return false;
1092
1093
            case DocLexer::T_NULL:
1094
                $this->match(DocLexer::T_NULL);
1095
                return null;
1096
1097
            default:
1098
                $this->syntaxError('PlainValue');
1099
        }
1100
    }
1101
1102
    /**
1103
     * FieldAssignment ::= FieldName "=" PlainValue
1104
     * FieldName ::= identifier
1105
     *
1106
     * @return \stdClass
1107
     */
1108
    private function FieldAssignment()
1109
    {
1110
        $this->match(DocLexer::T_IDENTIFIER);
1111
        $fieldName = $this->lexer->token['value'];
1112
1113
        $this->match(DocLexer::T_EQUALS);
1114
1115
        $item = new \stdClass();
1116
        $item->name  = $fieldName;
1117
        $item->value = $this->PlainValue();
1118
1119
        return $item;
1120
    }
1121
1122
    /**
1123
     * Array ::= "{" ArrayEntry {"," ArrayEntry}* [","] "}"
1124
     *
1125
     * @return array
1126
     */
1127
    private function Arrayx()
1128
    {
1129
        $array = $values = [];
1130
1131
        $this->match(DocLexer::T_OPEN_CURLY_BRACES);
1132
1133
        // If the array is empty, stop parsing and return.
1134
        if ($this->lexer->isNextToken(DocLexer::T_CLOSE_CURLY_BRACES)) {
1135
            $this->match(DocLexer::T_CLOSE_CURLY_BRACES);
1136
1137
            return $array;
1138
        }
1139
1140
        $values[] = $this->ArrayEntry();
1141
1142
        while ($this->lexer->isNextToken(DocLexer::T_COMMA)) {
1143
            $this->match(DocLexer::T_COMMA);
1144
1145
            // optional trailing comma
1146
            if ($this->lexer->isNextToken(DocLexer::T_CLOSE_CURLY_BRACES)) {
1147
                break;
1148
            }
1149
1150
            $values[] = $this->ArrayEntry();
1151
        }
1152
1153
        $this->match(DocLexer::T_CLOSE_CURLY_BRACES);
1154
1155
        foreach ($values as $value) {
1156
            list ($key, $val) = $value;
1157
1158
            if ($key !== null) {
1159
                $array[$key] = $val;
1160
            } else {
1161
                $array[] = $val;
1162
            }
1163
        }
1164
1165
        return $array;
1166
    }
1167
1168
    /**
1169
     * ArrayEntry ::= Value | KeyValuePair
1170
     * KeyValuePair ::= Key ("=" | ":") PlainValue | Constant
1171
     * Key ::= string | integer | Constant
1172
     *
1173
     * @return array
1174
     */
1175
    private function ArrayEntry()
1176
    {
1177
        $peek = $this->lexer->glimpse();
1178
1179
        if (DocLexer::T_EQUALS === $peek['type']
1180
                || DocLexer::T_COLON === $peek['type']) {
1181
1182
            if ($this->lexer->isNextToken(DocLexer::T_IDENTIFIER)) {
1183
                $key = $this->Constant();
1184
            } else {
1185
                $this->matchAny([DocLexer::T_INTEGER, DocLexer::T_STRING]);
1186
                $key = $this->lexer->token['value'];
1187
            }
1188
1189
            $this->matchAny([DocLexer::T_EQUALS, DocLexer::T_COLON]);
1190
1191
            return [$key, $this->PlainValue()];
1192
        }
1193
1194
        return [null, $this->Value()];
1195
    }
1196
1197
    /**
1198
     * Checks whether the given $name matches any ignored annotation name or namespace
1199
     *
1200
     * @param string $name
1201
     *
1202
     * @return bool
1203
     */
1204
    private function isIgnoredAnnotation($name)
1205
    {
1206
        if ($this->ignoreNotImportedAnnotations || isset($this->ignoredAnnotationNames[$name])) {
1207
            return true;
1208
        }
1209
1210
        foreach (array_keys($this->ignoredAnnotationNamespaces) as $ignoredAnnotationNamespace) {
1211
            $ignoredAnnotationNamespace = rtrim($ignoredAnnotationNamespace, '\\') . '\\';
1212
1213
            if (0 === stripos(rtrim($name, '\\') . '\\', $ignoredAnnotationNamespace)) {
1214
                return true;
1215
            }
1216
        }
1217
1218
        return false;
1219
    }
1220
}
1221