PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Dependencies / PhpDocReader / PhpDocReader.php

PhpDocReader.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.0, at includes/Dependencies/PhpDocReader/PhpDocReader.php

309 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php declare(strict_types=1);
2 // phpcs:ignoreFile -- Bundled third-party (Mozart) dependency; exempt from plugin coding standards.
3
4 namespace WPDeveloper\BetterDocs\Dependencies\PhpDocReader;
5
6 use WPDeveloper\BetterDocs\Dependencies\PhpDocReader\PhpParser\UseStatementParser;
7 use ReflectionClass;
8 use ReflectionMethod;
9 use ReflectionParameter;
10 use ReflectionProperty;
11 use Reflector;
12
13 /**
14 * PhpDoc reader
15 */
16 class PhpDocReader
17 {
18 /** @var UseStatementParser */
19 private $parser;
20
21 private const PRIMITIVE_TYPES = [
22 'bool' => 'bool',
23 'boolean' => 'bool',
24 'string' => 'string',
25 'int' => 'int',
26 'integer' => 'int',
27 'float' => 'float',
28 'double' => 'float',
29 'array' => 'array',
30 'object' => 'object',
31 'callable' => 'callable',
32 'resource' => 'resource',
33 'mixed' => 'mixed',
34 'iterable' => 'iterable',
35 ];
36
37 /** @var bool */
38 private $ignorePhpDocErrors;
39
40 /**
41 * @param bool $ignorePhpDocErrors Enable or disable throwing errors when PhpDoc errors occur (when parsing annotations).
42 */
43 public function __construct(bool $ignorePhpDocErrors = false)
44 {
45 $this->parser = new UseStatementParser;
46 $this->ignorePhpDocErrors = $ignorePhpDocErrors;
47 }
48
49 /**
50 * Parse the docblock of the property to get the type (class or primitive type) of the var annotation.
51 *
52 * @return string|null Type of the property (content of var annotation)
53 * @throws AnnotationException
54 */
55 public function getPropertyType(ReflectionProperty $property): ?string
56 {
57 return $this->readPropertyType($property, true);
58 }
59
60 /**
61 * Parse the docblock of the property to get the class of the var annotation.
62 *
63 * @return string|null Type of the property (content of var annotation)
64 * @throws AnnotationException
65 */
66 public function getPropertyClass(ReflectionProperty $property): ?string
67 {
68 return $this->readPropertyType($property, false);
69 }
70
71 private function readPropertyType(ReflectionProperty $property, bool $allowPrimitiveTypes): ?string
72 {
73 // Get the content of the @var annotation
74 $docComment = $property->getDocComment();
75 if (! $docComment) {
76 return null;
77 }
78 if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches)) {
79 [, $type] = $matches;
80 } else {
81 return null;
82 }
83
84 // Ignore primitive types
85 if (isset(self::PRIMITIVE_TYPES[$type])) {
86 if ($allowPrimitiveTypes) {
87 return self::PRIMITIVE_TYPES[$type];
88 }
89 return null;
90 }
91
92 // Ignore types containing special characters ([], <> ...)
93 if (! preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) {
94 return null;
95 }
96
97 $class = $property->getDeclaringClass();
98
99 // If the class name is not fully qualified (i.e. doesn't start with a \)
100 if ($type[0] !== '\\') {
101 // Try to resolve the FQN using the class context
102 $resolvedType = $this->tryResolveFqn($type, $class, $property);
103
104 if (! $resolvedType && ! $this->ignorePhpDocErrors) {
105 throw new AnnotationException(sprintf(
106 'The @var annotation on %s::%s contains a non existent class "%s". '
107 . 'Did you maybe forget to add a "use" statement for this annotation?',
108 $class->name,
109 $property->getName(),
110 $type
111 ));
112 }
113
114 $type = $resolvedType;
115 }
116
117 if (! $this->ignorePhpDocErrors && ! $this->classExists($type)) {
118 throw new AnnotationException(sprintf(
119 'The @var annotation on %s::%s contains a non existent class "%s"',
120 $class->name,
121 $property->getName(),
122 $type
123 ));
124 }
125
126 // Remove the leading \ (FQN shouldn't contain it)
127 $type = is_string($type) ? ltrim($type, '\\') : null;
128
129 return $type;
130 }
131
132 /**
133 * Parse the docblock of the property to get the type (class or primitive type) of the param annotation.
134 *
135 * @return string|null Type of the property (content of var annotation)
136 * @throws AnnotationException
137 */
138 public function getParameterType(ReflectionParameter $parameter): ?string
139 {
140 return $this->readParameterClass($parameter, true);
141 }
142
143 /**
144 * Parse the docblock of the property to get the class of the param annotation.
145 *
146 * @return string|null Type of the property (content of var annotation)
147 * @throws AnnotationException
148 */
149 public function getParameterClass(ReflectionParameter $parameter): ?string
150 {
151 return $this->readParameterClass($parameter, false);
152 }
153
154 private function readParameterClass(ReflectionParameter $parameter, bool $allowPrimitiveTypes): ?string
155 {
156 // Use reflection
157 $parameterType = $parameter->getType();
158 if ($parameterType && $parameterType instanceof \ReflectionNamedType && ! $parameterType->isBuiltin()) {
159 return $parameterType->getName();
160 }
161
162 $parameterName = $parameter->name;
163 // Get the content of the @param annotation
164 $method = $parameter->getDeclaringFunction();
165 $docComment = $method->getDocComment();
166 if (! $docComment) {
167 return null;
168 }
169 if (preg_match('/@param\s+([^\s]+)\s+\$' . $parameterName . '/', $docComment, $matches)) {
170 [, $type] = $matches;
171 } else {
172 return null;
173 }
174
175 // Ignore primitive types
176 if (isset(self::PRIMITIVE_TYPES[$type])) {
177 if ($allowPrimitiveTypes) {
178 return self::PRIMITIVE_TYPES[$type];
179 }
180 return null;
181 }
182
183 // Ignore types containing special characters ([], <> ...)
184 if (! preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) {
185 return null;
186 }
187
188 $class = $parameter->getDeclaringClass();
189
190 // If the class name is not fully qualified (i.e. doesn't start with a \)
191 if ($type[0] !== '\\') {
192 // Try to resolve the FQN using the class context
193 $resolvedType = $this->tryResolveFqn($type, $class, $parameter);
194
195 if (! $resolvedType && ! $this->ignorePhpDocErrors) {
196 throw new AnnotationException(sprintf(
197 'The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s". '
198 . 'Did you maybe forget to add a "use" statement for this annotation?',
199 $parameterName,
200 $class->name,
201 $method->name,
202 $type
203 ));
204 }
205
206 $type = $resolvedType;
207 }
208
209 if (! $this->ignorePhpDocErrors && ! $this->classExists($type)) {
210 throw new AnnotationException(sprintf(
211 'The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s"',
212 $parameterName,
213 $class->name,
214 $method->name,
215 $type
216 ));
217 }
218
219 // Remove the leading \ (FQN shouldn't contain it)
220 $type = is_string($type) ? ltrim($type, '\\') : null;
221
222 return $type;
223 }
224
225 /**
226 * Attempts to resolve the FQN of the provided $type based on the $class and $member context.
227 *
228 * @return string|null Fully qualified name of the type, or null if it could not be resolved
229 */
230 private function tryResolveFqn(string $type, ReflectionClass $class, Reflector $member): ?string
231 {
232 $alias = ($pos = strpos($type, '\\')) === false ? $type : substr($type, 0, $pos);
233 $loweredAlias = strtolower($alias);
234
235 // Retrieve "use" statements
236 $uses = $this->parser->parseUseStatements($class);
237
238 if (isset($uses[$loweredAlias])) {
239 // Imported classes
240 if ($pos !== false) {
241 return $uses[$loweredAlias] . substr($type, $pos);
242 }
243 return $uses[$loweredAlias];
244 }
245
246 if ($this->classExists($class->getNamespaceName() . '\\' . $type)) {
247 return $class->getNamespaceName() . '\\' . $type;
248 }
249
250 if (isset($uses['__NAMESPACE__']) && $this->classExists($uses['__NAMESPACE__'] . '\\' . $type)) {
251 // Class namespace
252 return $uses['__NAMESPACE__'] . '\\' . $type;
253 }
254
255 if ($this->classExists($type)) {
256 // No namespace
257 return $type;
258 }
259
260 // If all fail, try resolving through related traits
261 return $this->tryResolveFqnInTraits($type, $class, $member);
262 }
263
264 /**
265 * Attempts to resolve the FQN of the provided $type based on the $class and $member context, specifically searching
266 * through the traits that are used by the provided $class.
267 *
268 * @return string|null Fully qualified name of the type, or null if it could not be resolved
269 */
270 private function tryResolveFqnInTraits(string $type, ReflectionClass $class, Reflector $member): ?string
271 {
272 /** @var ReflectionClass[] $traits */
273 $traits = [];
274
275 // Get traits for the class and its parents
276 while ($class) {
277 $traits = array_merge($traits, $class->getTraits());
278 $class = $class->getParentClass();
279 }
280
281 foreach ($traits as $trait) {
282 // Eliminate traits that don't have the property/method/parameter
283 if ($member instanceof ReflectionProperty && ! $trait->hasProperty($member->name)) {
284 continue;
285 }
286 if ($member instanceof ReflectionMethod && ! $trait->hasMethod($member->name)) {
287 continue;
288 }
289 if ($member instanceof ReflectionParameter && ! $trait->hasMethod($member->getDeclaringFunction()->name)) {
290 continue;
291 }
292
293 // Run the resolver again with the ReflectionClass instance for the trait
294 $resolvedType = $this->tryResolveFqn($type, $trait, $member);
295
296 if ($resolvedType) {
297 return $resolvedType;
298 }
299 }
300
301 return null;
302 }
303
304 private function classExists(string $class): bool
305 {
306 return class_exists($class) || interface_exists($class);
307 }
308 }
309