Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.97% covered (success)
98.97%
96 / 97
83.33% covered (success)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AnnotationReader
98.97% covered (success)
98.97%
96 / 97
83.33% covered (success)
83.33%
5 / 6
35
0.00% covered (danger)
0.00%
0 / 1
 getClass
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getProperties
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 getUseStatements
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 parseDocBlock
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
8
 injectProperties
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 parseProperties
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace Miniframe\Annotation\Service;
4
5use Miniframe\Annotation\Model\Property;
6use Miniframe\Annotation\Model\ClassInfo;
7use Miniframe\Annotation\Annotation\BaseAnnotation;
8
9class AnnotationReader
10{
11    /**
12     * Returns metadata about a class, including annotated properties
13     *
14     * @param \ReflectionClass $reflectionClass A reflection of the class (new \ReflectionClass($class)).
15     *
16     * @return ClassInfo
17     */
18    public function getClass(\ReflectionClass $reflectionClass): ClassInfo
19    {
20        return new ClassInfo(
21            $reflectionClass->getName(),
22            $this->parseDocBlock($reflectionClass, $reflectionClass->getDocComment()),
23            $this->getProperties($reflectionClass)
24        );
25    }
26
27    /**
28     * Returns an array with all properties in the reflected class, including parsed doc blocks.
29     *
30     * @param \ReflectionClass $rc The reflected class.
31     *
32     * @return Property[]
33     */
34    private function getProperties(\ReflectionClass $rc): array
35    {
36        $properties = $rc->getProperties();
37
38        $return = array();
39        foreach ($properties as $property) {
40            if ($property->isStatic()) {
41                continue;
42            }
43
44            if ($property->isPrivate()) {
45                $access = 'private';
46            } elseif ($property->isProtected()) {
47                $access = 'protected';
48            } elseif ($property->isPublic()) {
49                $access = 'public';
50            } else {
51                throw new \RuntimeException('The property is not defined properly');
52            }
53
54            $return[$property->getName()] = new Property(
55                $property->getName(),
56                $access,
57                $this->parseDocBlock($rc, $property->getDocComment())
58            );
59        }
60
61        return $return;
62    }
63
64    /**
65     * Fetches an array of use statements in a class file and returns a string with the alias as key and FQCN as value
66     *
67     * @param \ReflectionClass $rc     The reflection class.
68     * @param string           $prefix Optionally a prefix that's prepended to the key and value.
69     *
70     * @return string[]
71     */
72    private function getUseStatements(\ReflectionClass $rc, string $prefix = ''): array
73    {
74        $filename = $rc->getFileName();
75
76        $data = file_get_contents($filename);
77        preg_match_all(
78            "/(^|;)[\s]*use[\s]+(?P<class>.*?)(|[\s]+as[\s]+(?<alias>.*?))[\s]*;/mu",
79            $data,
80            $matches,
81            PREG_SET_ORDER
82        );
83        $return = array();
84        foreach ($matches as $match) {
85            $short = array_reverse(explode('\\', $match['class']))[0];
86            $alias = $prefix . ($match['alias'] ?? $short);
87            $return[$alias] = $prefix . $match['class'];
88        }
89        return $return;
90    }
91
92    /**
93     * Parses a docblock string, returning annotation classes
94     *
95     * @param \ReflectionClass $rc       Reflection class.
96     * @param string           $docblock The docblock string.
97     *
98     * @return object[]
99     */
100    private function parseDocBlock(\ReflectionClass $rc, string $docblock): array
101    {
102        if (
103            substr($docblock, 0, 3) !== '/**'
104            || substr($docblock, -2) !== '*/'
105        ) {
106            return [];
107        }
108
109        // Replace @Annotation to @Full\Namespace\Annotation
110        $useStatements = $this->getUseStatements($rc, '@');
111        $docblock = str_replace(
112            array_keys($useStatements),
113            array_values($useStatements),
114            $docblock
115        );
116
117        // Remove start en end of docblock
118        $docblock = substr($docblock, 3, -2);
119
120        // Remove the asterisk signs at the beginning of each line
121        $docblock = preg_replace('/^[ \t]*\*[ \t]*/m', '', $docblock);
122
123        $return = array();
124        $annotations = preg_split('/^@/m', $docblock);
125        foreach ($annotations as $iterator => $annotation) {
126            // First part is always title + description (which both may be empty)
127            if ($iterator === 0) {
128                $split = explode("\n", trim($annotation), 2);
129                $return['_title'] = trim($split[0]) ?? null;
130                $return['_description'] = trim($split[1] ?? '') ?? null;
131                continue;
132            }
133
134            // We've got an annotation, let's split its name and its properties
135            $split = preg_split('/[\s\(]+/', $annotation, 2);
136            $annotationClassname = trim($split[0]);
137            $annotationProperties = substr($annotation, strlen($annotationClassname));
138
139            // Auto guess namespace
140            if (!class_exists($annotationClassname) && class_exists('Miniframe\\Annotation\\' . $annotationClassname)) {
141                $annotationClassname = 'Miniframe\\Annotation\\' . $annotationClassname;
142            }
143
144            // Isn't it an annotation class? Then treat it as a generic string
145            if (!class_exists($annotationClassname)) {
146                $return[strtolower($annotationClassname)] = new BaseAnnotation(
147                    $annotationClassname,
148                    trim($annotationProperties)
149                );
150                continue;
151            }
152
153            // Is it an annotation class? Initiate the class.
154            $return[$annotationClassname] = $this->injectProperties(
155                $annotationClassname,
156                $this->parseProperties($annotationProperties)
157            );
158        }
159        return $return;
160    }
161
162    /**
163     * Converts an annotation class name and an array of properties to an annotation object
164     *
165     * @param string $annotationClassname  Name of the annotation class.
166     * @param array  $annotationProperties Array of properties.
167     *
168     * @return object
169     */
170    private function injectProperties(string $annotationClassname, array $annotationProperties): object
171    {
172        $annotationInfo = $this->getClass(new \ReflectionClass($annotationClassname));
173
174        try {
175            $annotationClass = new $annotationClassname();
176        } catch (\Throwable $throwable) {
177            // If loading the annotation class fails, fall back to a base annotation
178            return new BaseAnnotation($annotationClassname, '');
179        }
180        foreach ($annotationProperties as $property => $value) {
181            $types = strtolower($annotationInfo->getProperty($property)->getAnnotation('var')->value ?? 'string');
182            $type = explode('|', $types, 2)[0];
183            switch ($type) {
184                case 'int':
185                case 'integer':
186                    $annotationClass->$property = intval($value, 10);
187                    break;
188                case 'float':
189                    $annotationClass->$property = floatval($value);
190                    break;
191                case 'bool':
192                case 'boolean':
193                    $annotationClass->$property =
194                        ($value === '' || $value === '1' || $value === 'true' || $value === true);
195                    break;
196                default:
197                    $annotationClass->$property = $value;
198            }
199        }
200        return $annotationClass;
201    }
202
203    /**
204     * Returns an array with key 'property' and it's value
205     *
206     * @param string $properties The data as string.
207     *
208     * @return array
209     */
210    private function parseProperties(string $properties): array
211    {
212        preg_match('/^[\s]*\((.*?)\)[\s]*$/s', $properties, $matches);
213        if (!isset($matches[1])) {
214            return [];
215        }
216        $properties = $matches[1];
217
218        preg_match_all(
219            '/(?P<property>[\w]+)(\=("(?P<value1>.*?)"|\'(?P<value2>.*?)\'|(?P<value3>[^\s,]*))|)([\s]+|$|,)/msu',
220            $properties,
221            $matches,
222            PREG_SET_ORDER
223        );
224
225        $return = array();
226        foreach ($matches as $match) {
227            $property = $match['property'];
228            if (!empty($match['value1'])) { // Value encapsulated with double quotes (")
229                $value = $match['value1'];
230            } elseif (!empty($match['value2'])) { // Value encapsulated with single quotes (')
231                $value = $match['value2'];
232            } elseif (!empty($match['value3'])) { // Non-encapsulated value
233                $value = $match['value3'];
234            } else {
235                $value = true;
236            }
237            $return[$property] = $value;
238        }
239
240        return $return;
241    }
242}