first commit

This commit is contained in:
2026-03-24 00:31:47 +01:00
commit 2506f6f9c7
3328 changed files with 1172155 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
Copyright (C) 2019 Matthieu Napoli
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,27 @@
{
"name": "php-di\/phpdoc-reader",
"type": "library",
"description": "PhpDocReader parses @var and @param values in PHP docblocks (supports namespaced class names with the same resolution rules as PHP)",
"keywords": [
"phpdoc",
"reflection"
],
"license": "MIT",
"autoload": {
"psr-4": {
"ElementorProDeps\\PhpDocReader\\": "src\/PhpDocReader"
}
},
"autoload-dev": {
"psr-4": {
"ElementorProDeps\\UnitTest\\PhpDocReader\\": "tests\/"
}
},
"require": {
"php": ">=7.2.0"
},
"require-dev": {
"phpunit\/phpunit": "^8.5|^9.0",
"mnapoli\/hard-mode": "~0.3.0"
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare (strict_types=1);
namespace ElementorProDeps\PhpDocReader;
/**
* We stumbled upon an invalid class/property/method annotation.
*/
class AnnotationException extends \Exception
{
}

View File

@@ -0,0 +1,227 @@
<?php
declare (strict_types=1);
namespace ElementorProDeps\PhpDocReader;
use ElementorProDeps\PhpDocReader\PhpParser\UseStatementParser;
use ReflectionClass;
use ReflectionMethod;
use ReflectionParameter;
use ReflectionProperty;
use Reflector;
/**
* PhpDoc reader
*/
class PhpDocReader
{
/** @var UseStatementParser */
private $parser;
private const PRIMITIVE_TYPES = ['bool' => 'bool', 'boolean' => 'bool', 'string' => 'string', 'int' => 'int', 'integer' => 'int', 'float' => 'float', 'double' => 'float', 'array' => 'array', 'object' => 'object', 'callable' => 'callable', 'resource' => 'resource', 'mixed' => 'mixed', 'iterable' => 'iterable'];
/** @var bool */
private $ignorePhpDocErrors;
/**
* @param bool $ignorePhpDocErrors Enable or disable throwing errors when PhpDoc errors occur (when parsing annotations).
*/
public function __construct(bool $ignorePhpDocErrors = \false)
{
$this->parser = new UseStatementParser();
$this->ignorePhpDocErrors = $ignorePhpDocErrors;
}
/**
* Parse the docblock of the property to get the type (class or primitive type) of the var annotation.
*
* @return string|null Type of the property (content of var annotation)
* @throws AnnotationException
*/
public function getPropertyType(ReflectionProperty $property) : ?string
{
return $this->readPropertyType($property, \true);
}
/**
* Parse the docblock of the property to get the class of the var annotation.
*
* @return string|null Type of the property (content of var annotation)
* @throws AnnotationException
*/
public function getPropertyClass(ReflectionProperty $property) : ?string
{
return $this->readPropertyType($property, \false);
}
private function readPropertyType(ReflectionProperty $property, bool $allowPrimitiveTypes) : ?string
{
// Get the content of the @var annotation
$docComment = $property->getDocComment();
if (!$docComment) {
return null;
}
if (\preg_match('/@var\\s+([^\\s]+)/', $docComment, $matches)) {
[, $type] = $matches;
} else {
return null;
}
// Ignore primitive types
if (isset(self::PRIMITIVE_TYPES[$type])) {
if ($allowPrimitiveTypes) {
return self::PRIMITIVE_TYPES[$type];
}
return null;
}
// Ignore types containing special characters ([], <> ...)
if (!\preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) {
return null;
}
$class = $property->getDeclaringClass();
// If the class name is not fully qualified (i.e. doesn't start with a \)
if ($type[0] !== '\\') {
// Try to resolve the FQN using the class context
$resolvedType = $this->tryResolveFqn($type, $class, $property);
if (!$resolvedType && !$this->ignorePhpDocErrors) {
throw new AnnotationException(\sprintf('The @var annotation on %s::%s contains a non existent class "%s". ' . 'Did you maybe forget to add a "use" statement for this annotation?', $class->name, $property->getName(), $type));
}
$type = $resolvedType;
}
if (!$this->ignorePhpDocErrors && !$this->classExists($type)) {
throw new AnnotationException(\sprintf('The @var annotation on %s::%s contains a non existent class "%s"', $class->name, $property->getName(), $type));
}
// Remove the leading \ (FQN shouldn't contain it)
$type = \is_string($type) ? \ltrim($type, '\\') : null;
return $type;
}
/**
* Parse the docblock of the property to get the type (class or primitive type) of the param annotation.
*
* @return string|null Type of the property (content of var annotation)
* @throws AnnotationException
*/
public function getParameterType(ReflectionParameter $parameter) : ?string
{
return $this->readParameterClass($parameter, \true);
}
/**
* Parse the docblock of the property to get the class of the param annotation.
*
* @return string|null Type of the property (content of var annotation)
* @throws AnnotationException
*/
public function getParameterClass(ReflectionParameter $parameter) : ?string
{
return $this->readParameterClass($parameter, \false);
}
private function readParameterClass(ReflectionParameter $parameter, bool $allowPrimitiveTypes) : ?string
{
// Use reflection
$parameterType = $parameter->getType();
if ($parameterType && $parameterType instanceof \ReflectionNamedType && !$parameterType->isBuiltin()) {
return $parameterType->getName();
}
$parameterName = $parameter->name;
// Get the content of the @param annotation
$method = $parameter->getDeclaringFunction();
$docComment = $method->getDocComment();
if (!$docComment) {
return null;
}
if (\preg_match('/@param\\s+([^\\s]+)\\s+\\$' . $parameterName . '/', $docComment, $matches)) {
[, $type] = $matches;
} else {
return null;
}
// Ignore primitive types
if (isset(self::PRIMITIVE_TYPES[$type])) {
if ($allowPrimitiveTypes) {
return self::PRIMITIVE_TYPES[$type];
}
return null;
}
// Ignore types containing special characters ([], <> ...)
if (!\preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) {
return null;
}
$class = $parameter->getDeclaringClass();
// If the class name is not fully qualified (i.e. doesn't start with a \)
if ($type[0] !== '\\') {
// Try to resolve the FQN using the class context
$resolvedType = $this->tryResolveFqn($type, $class, $parameter);
if (!$resolvedType && !$this->ignorePhpDocErrors) {
throw new AnnotationException(\sprintf('The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s". ' . 'Did you maybe forget to add a "use" statement for this annotation?', $parameterName, $class->name, $method->name, $type));
}
$type = $resolvedType;
}
if (!$this->ignorePhpDocErrors && !$this->classExists($type)) {
throw new AnnotationException(\sprintf('The @param annotation for parameter "%s" of %s::%s contains a non existent class "%s"', $parameterName, $class->name, $method->name, $type));
}
// Remove the leading \ (FQN shouldn't contain it)
$type = \is_string($type) ? \ltrim($type, '\\') : null;
return $type;
}
/**
* Attempts to resolve the FQN of the provided $type based on the $class and $member context.
*
* @return string|null Fully qualified name of the type, or null if it could not be resolved
*/
private function tryResolveFqn(string $type, ReflectionClass $class, Reflector $member) : ?string
{
$alias = ($pos = \strpos($type, '\\')) === \false ? $type : \substr($type, 0, $pos);
$loweredAlias = \strtolower($alias);
// Retrieve "use" statements
$uses = $this->parser->parseUseStatements($class);
if (isset($uses[$loweredAlias])) {
// Imported classes
if ($pos !== \false) {
return $uses[$loweredAlias] . \substr($type, $pos);
}
return $uses[$loweredAlias];
}
if ($this->classExists($class->getNamespaceName() . '\\' . $type)) {
return $class->getNamespaceName() . '\\' . $type;
}
if (isset($uses['__NAMESPACE__']) && $this->classExists($uses['__NAMESPACE__'] . '\\' . $type)) {
// Class namespace
return $uses['__NAMESPACE__'] . '\\' . $type;
}
if ($this->classExists($type)) {
// No namespace
return $type;
}
// If all fail, try resolving through related traits
return $this->tryResolveFqnInTraits($type, $class, $member);
}
/**
* Attempts to resolve the FQN of the provided $type based on the $class and $member context, specifically searching
* through the traits that are used by the provided $class.
*
* @return string|null Fully qualified name of the type, or null if it could not be resolved
*/
private function tryResolveFqnInTraits(string $type, ReflectionClass $class, Reflector $member) : ?string
{
/** @var ReflectionClass[] $traits */
$traits = [];
// Get traits for the class and its parents
while ($class) {
$traits = \array_merge($traits, $class->getTraits());
$class = $class->getParentClass();
}
foreach ($traits as $trait) {
// Eliminate traits that don't have the property/method/parameter
if ($member instanceof ReflectionProperty && !$trait->hasProperty($member->name)) {
continue;
}
if ($member instanceof ReflectionMethod && !$trait->hasMethod($member->name)) {
continue;
}
if ($member instanceof ReflectionParameter && !$trait->hasMethod($member->getDeclaringFunction()->name)) {
continue;
}
// Run the resolver again with the ReflectionClass instance for the trait
$resolvedType = $this->tryResolveFqn($type, $trait, $member);
if ($resolvedType) {
return $resolvedType;
}
}
return null;
}
private function classExists(string $class) : bool
{
return \class_exists($class) || \interface_exists($class);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare (strict_types=1);
namespace ElementorProDeps\PhpDocReader\PhpParser;
/**
* Parses a file for namespaces/use/class declarations.
*
* Class taken and adapted from doctrine/annotations to avoid pulling the whole package.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Christian Kaps <christian.kaps@mohiva.com>
*/
class TokenParser
{
/**
* The token list.
*
* @var list<mixed[]>
*/
private $tokens;
/**
* The number of tokens.
*
* @var int
*/
private $numTokens;
/**
* The current array pointer.
*
* @var int
*/
private $pointer = 0;
/**
* @param string $contents
*/
public function __construct($contents)
{
$this->tokens = \token_get_all($contents);
// The PHP parser sets internal compiler globals for certain things. Annoyingly, the last docblock comment it
// saw gets stored in doc_comment. When it comes to compile the next thing to be include()d this stored
// doc_comment becomes owned by the first thing the compiler sees in the file that it considers might have a
// docblock. If the first thing in the file is a class without a doc block this would cause calls to
// getDocBlock() on said class to return our long lost doc_comment. Argh.
// To workaround, cause the parser to parse an empty docblock. Sure getDocBlock() will return this, but at least
// it's harmless to us.
\token_get_all("<?php\n/**\n *\n */");
$this->numTokens = \count($this->tokens);
}
/**
* Gets all use statements.
*
* @param string $namespaceName The namespace name of the reflected class.
*
* @return array<string, string> A list with all found use statements.
*/
public function parseUseStatements($namespaceName)
{
$statements = [];
while ($token = $this->next()) {
if ($token[0] === \T_USE) {
$statements = \array_merge($statements, $this->parseUseStatement());
continue;
}
if ($token[0] !== \T_NAMESPACE || $this->parseNamespace() !== $namespaceName) {
continue;
}
// Get fresh array for new namespace. This is to prevent the parser to collect the use statements
// for a previous namespace with the same name. This is the case if a namespace is defined twice
// or if a namespace with the same name is commented out.
$statements = [];
}
return $statements;
}
/**
* Gets the next non whitespace and non comment token.
*
* @param bool $docCommentIsComment If TRUE then a doc comment is considered a comment and skipped.
* If FALSE then only whitespace and normal comments are skipped.
*
* @return mixed[]|string|null The token if exists, null otherwise.
*/
private function next($docCommentIsComment = \true)
{
for ($i = $this->pointer; $i < $this->numTokens; $i++) {
$this->pointer++;
if ($this->tokens[$i][0] === \T_WHITESPACE || $this->tokens[$i][0] === \T_COMMENT || $docCommentIsComment && $this->tokens[$i][0] === \T_DOC_COMMENT) {
continue;
}
return $this->tokens[$i];
}
return null;
}
/**
* Parses a single use statement.
*
* @return array<string, string> A list with all found class names for a use statement.
*/
private function parseUseStatement()
{
$groupRoot = '';
$class = '';
$alias = '';
$statements = [];
$explicitAlias = \false;
while ($token = $this->next()) {
if (!$explicitAlias && $token[0] === \T_STRING) {
$class .= $token[1];
$alias = $token[1];
} elseif ($explicitAlias && $token[0] === \T_STRING) {
$alias = $token[1];
} elseif (\PHP_VERSION_ID >= 80000 && ($token[0] === \T_NAME_QUALIFIED || $token[0] === \T_NAME_FULLY_QUALIFIED)) {
$class .= $token[1];
$classSplit = \explode('\\', $token[1]);
$alias = $classSplit[\count($classSplit) - 1];
} elseif ($token[0] === \T_NS_SEPARATOR) {
$class .= '\\';
$alias = '';
} elseif ($token[0] === \T_AS) {
$explicitAlias = \true;
$alias = '';
} elseif ($token === ',') {
$statements[\strtolower($alias)] = $groupRoot . $class;
$class = '';
$alias = '';
$explicitAlias = \false;
} elseif ($token === ';') {
$statements[\strtolower($alias)] = $groupRoot . $class;
break;
} elseif ($token === '{') {
$groupRoot = $class;
$class = '';
} elseif ($token === '}') {
continue;
} else {
break;
}
}
return $statements;
}
/**
* Gets the namespace.
*
* @return string The found namespace.
*/
private function parseNamespace()
{
$name = '';
while (($token = $this->next()) && ($token[0] === \T_STRING || $token[0] === \T_NS_SEPARATOR || \PHP_VERSION_ID >= 80000 && ($token[0] === \T_NAME_QUALIFIED || $token[0] === \T_NAME_FULLY_QUALIFIED))) {
$name .= $token[1];
}
return $name;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare (strict_types=1);
namespace ElementorProDeps\PhpDocReader\PhpParser;
use SplFileObject;
/**
* Parses a file for "use" declarations.
*
* Class taken and adapted from doctrine/annotations to avoid pulling the whole package.
*
* Authors: Fabien Potencier <fabien@symfony.com> and Christian Kaps <christian.kaps@mohiva.com>
*/
class UseStatementParser
{
/**
* @return array A list with use statements in the form (Alias => FQN).
*/
public function parseUseStatements(\ReflectionClass $class) : array
{
$filename = $class->getFilename();
if ($filename === \false) {
return [];
}
$content = $this->getFileContent($filename, $class->getStartLine());
if ($content === null) {
return [];
}
$namespace = \preg_quote($class->getNamespaceName(), '/');
$content = \preg_replace('/^.*?(\\bnamespace\\s+' . $namespace . '\\s*[;{].*)$/s', '\\1', $content);
$tokenizer = new TokenParser('<?php ' . $content);
return $tokenizer->parseUseStatements($class->getNamespaceName());
}
/**
* Gets the content of the file right up to the given line number.
*
* @param string $filename The name of the file to load.
* @param int $lineNumber The number of lines to read from file.
*/
private function getFileContent(string $filename, int $lineNumber) : string
{
if (!\is_file($filename)) {
throw new \RuntimeException("Unable to read file {$filename}");
}
$content = '';
$lineCnt = 0;
$file = new SplFileObject($filename);
while (!$file->eof()) {
if ($lineCnt++ === $lineNumber) {
break;
}
$content .= $file->fgets();
}
return $content;
}
}