bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Abstraction/AbstractObjectProperties.php

Summary

Maintainability
C
7 hrs
Test Coverage
A
90%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2022 Brad Kent
 * @version   v3.0
 */

namespace bdk\Debug\Abstraction;

use bdk\Debug\Abstraction\Abstracter;
use bdk\Debug\Abstraction\Abstraction;
use bdk\Debug\Abstraction\AbstractObject;
use ReflectionFunction;
use ReflectionProperty;

/**
 * Get object property info
 */
class AbstractObjectProperties
{
    protected $abstracter;
    protected $helper;

    private static $basePropInfo = array(
        'attributes' => array(),
        'debugInfoExcluded' => false,   // true if not included in __debugInfo
        'desc' => null,                 // from phpDoc
        'inheritedFrom' => null,        // populated only if inherited
                                        //   not populated if extended/redefined
        'isPromoted' => false,
        'isReadOnly' => false,
        'isStatic' => false,
        'originallyDeclared' => null,   // populated only if originally declared in ancestor
        'overrides' => null,            // previous ancestor where property is defined
                                        //   populated only if we're overriding
        'forceShow' => false,           // initially show the property/value (even if protected or private)
                                        //   if value is an array, expand it
        'type' => null,
        'value' => Abstracter::UNDEFINED,
        'valueFrom' => 'value',         // 'value' | 'debugInfo' | 'debug'
        'visibility' => 'public',       // public, private, protected, magic, magic-read, magic-write, debug
                                        //   may also be an array (ie: ['private', 'magic-read'])
    );

    private $domNodeProps = array(
        'attributes' => 'DOMNamedNodeMap',
        'childNodes' => 'DOMNodeList',
        'firstChild' => 'DOMNode',
        'lastChild' => 'DOMNode',
        'localName' => 'string',
        'namespaceURI' => 'string',
        'nextSibling' => 'DOMNode', // var_dump() doesn't include ¯\_(ツ)_/¯
        'nodeName' => 'string',
        'nodeType' => 'int',
        'nodeValue' => 'string',
        'ownerDocument' => 'DOMDocument',
        'parentNode' => 'DOMNode',
        'prefix' => 'string',
        'previousSibling' => 'DOMNode',
        'textContent' => 'string',
    );

    private $domDocumentProps = array(
        'actualEncoding' => 'string',
        'baseURI' => 'string',
        'config' => 'DOMConfiguration',
        'doctype' => 'DOMDocumentType',
        'documentElement' => 'DOMElement',
        'documentURI' => 'string',
        'encoding' => 'string',
        'formatOutput' => 'bool',
        'implementation' => 'DOMImplementation',
        'preserveWhiteSpace' => 'bool',
        'recover' => 'bool',
        'resolveExternals' => 'bool',
        'standalone' => 'bool',
        'strictErrorChecking' => 'bool',
        'substituteEntities' => 'bool',
        'validateOnParse' => 'bool',
        'version' => 'string',
        'xmlEncoding' => 'string',
        'xmlStandalone' => 'bool',
        'xmlVersion' => 'string',
    );

    private $domElementProps = array(
        'schemaTypeInfo' => 'bool',
        'tagName' => 'string',
    );

    private $magicPhpDocTags = array(
        'property' => 'magic',
        'property-read' => 'magic-read',
        'property-write' => 'magic-write',
    );

    /**
     * Constructor
     *
     * @param Abstracter           $abstracter abstracter instance
     * @param AbstractObjectHelper $helper     helper class
     */
    public function __construct(Abstracter $abstracter, AbstractObjectHelper $helper)
    {
        $this->abstracter = $abstracter;
        $this->helper = $helper;
    }

    /**
     * Add property info/values to abstraction
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    public function add(Abstraction $abs)
    {
        if ($abs['className'] === 'Closure') {
            $this->addClosure($abs);
        }
        if ($abs['isTraverseOnly']) {
            return;
        }
        $this->abs = $abs;
        $this->addViaRef($abs);
        $this->addViaPhpDoc($abs); // magic properties documented via phpDoc
        $obj = $abs->getSubject();
        if (\is_object($obj)) {
            $this->addDom($abs);
            $this->addDebug($abs); // use __debugInfo() values if useDebugInfo' && method exists
        }
        $this->crate($abs);
    }

    /**
     * Build property info buy passing values
     *
     * @param array $values values to apply
     *
     * @return array
     */
    public static function buildPropValues($values = array())
    {
        return \array_merge(static::$basePropInfo, $values);
    }

    /**
     * Add file & line debug properties for Closure
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    private function addClosure(Abstraction $abs)
    {
        $obj = $abs->getSubject();
        $ref = new ReflectionFunction($obj);
        $abs['definition'] = array(
            'fileName' => $ref->getFileName(),
            'startLine' => $ref->getStartLine(),
            'extensionName' => $ref->getExtensionName(),
        );
        $abs['properties']['debug.file'] = static::buildPropValues(array(
            'type' => Abstracter::TYPE_STRING,
            'value' => $ref->getFileName(),
            'valueFrom' => 'debug',
            'visibility' => 'debug',
        ));
        $abs['properties']['debug.line'] = static::buildPropValues(array(
            'type' => Abstracter::TYPE_INT,
            'value' => $ref->getStartLine(),
            'valueFrom' => 'debug',
            'visibility' => 'debug',
        ));
    }

    /**
     * Add/Update properties with info from __debugInfo method
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    private function addDebug(Abstraction $abs)
    {
        if (!$abs['collectPropertyValues']) {
            return;
        }
        if (!$abs['viaDebugInfo']) {
            // using __debugInfo is disabled, or object does not have __debugInfo method
            return;
        }
        $obj = $abs->getSubject();
        // temporarily store __debugInfo values in abstraction
        $abs['debugInfo'] = \call_user_func(array($obj, '__debugInfo'));
        $properties = $this->addDebugWalk($abs);
        /*
            What remains in debugInfo are __debugInfo only values
        */
        foreach ($abs['debugInfo'] as $name => $value) {
            $properties[$name] = static::buildPropValues(array(
                'value' => $value,
                'valueFrom' => 'debugInfo',
                'visibility' => 'debug',    // indicates this "property" is exclusive to debugInfo
            ));
        }
        $abs['properties'] = $properties;
        unset($abs['debugInfo']);
    }

    /**
     * Iterate over properties to set value & valueFrom
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return array
     */
    private function addDebugWalk(Abstraction $abs)
    {
        $debugInfo = $abs['debugInfo'];
        $properties = $abs['properties'];
        foreach ($properties as $name => $info) {
            if (\array_key_exists($name, $abs['propertyOverrideValues'])) {
                // we're using override value
                unset($debugInfo[$name]);
                continue;
            }
            if (\array_key_exists($name, $debugInfo)) {
                if ($debugInfo[$name] !== $info['value']) {
                    $properties[$name]['value'] = $debugInfo[$name];
                    $properties[$name]['valueFrom'] = 'debugInfo';
                }
                unset($debugInfo[$name]);
                continue;
            }
            $isPrivateAncestor = \in_array('private', (array) $info['visibility'], true) && $info['inheritedFrom'];
            $properties[$name]['debugInfoExcluded'] = $isPrivateAncestor === false;
        }
        $abs['debugInfo'] = $debugInfo;
        return $properties;
    }

    /**
     * Add properties to Dom* abstraction
     *
     * DOM* properties are invisible to reflection
     * https://bugs.php.net/bug.php?id=48527
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    private function addDom(Abstraction $abs)
    {
        $obj = $abs->getSubject();
        if ($abs['properties']) {
            return;
        }
        if ($this->isDomObj($obj) === false) {
            return;
        }
        // for php < 8.1
        $props = $this->addDomGetProps($obj);
        foreach ($props as $propName => $type) {
            $val = $obj->{$propName};
            if (!$type) {
                // function array dereferencing = php 5.4
                $type = $this->abstracter->getType($val)[0];
            }
            $abs['properties'][$propName] = static::buildPropValues(array(
                'type' => $type,
                'value' => \is_object($val)
                    ? Abstracter::NOT_INSPECTED
                    : $val,
            ));
        }
    }

    /**
     * use print_r to get the property names
     * get_object_vars() doesn't work
     * var_dump may be overridden by xdebug...  and if xdebug v3 unable to disable at runtime
     *
     * PHP < 8.1
     *
     * @param object $obj DOMXXX instance
     *
     * @return array
     *
     * @SuppressWarnings(PHPMD.DevelopmentCodeFragment)
     */
    private function addDomGetProps($obj)
    {
        $dump = \print_r($obj, true);
        $matches = array();
        \preg_match_all('/^\s+\[(.+?)\] => /m', $dump, $matches);
        $props = \array_fill_keys($matches[1], null);
        if ($obj instanceof \DOMNode) {
            $props = \array_merge($props, $this->domNodeProps);
            if ($obj instanceof \DOMDocument) {
                $props = \array_merge($props, $this->domDocumentProps);
            } elseif ($obj instanceof \DOMElement) {
                $props = \array_merge($props, $this->domElementProps);
            }
        }
        return $props;
    }

    /**
     * "Magic" properties may be defined in a class' doc-block
     * If so... move this information to the properties array
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     *
     * @see http://docs.phpdoc.org/references/phpdoc/tags/property.html
     */
    private function addViaPhpDoc(Abstraction $abs)
    {
        $inheritedFrom = null;
        $haveMagic = \array_intersect_key($abs['phpDoc'], $this->magicPhpDocTags);
        $obj = $abs->getSubject();
        if (!$haveMagic && \method_exists($obj, '__get')) {
            // phpDoc doesn't contain any @property tags
            // we've got __get method:  check if parent classes have @property tags
            $inheritedFrom = $this->addViaPhpDocInherit($abs);
            $haveMagic = $inheritedFrom !== null;
        }
        if (!$haveMagic) {
            return;
        }
        $this->addViaPhpDocIter($abs, $inheritedFrom);
    }

    /**
     * Inspect inherited classes until we find properties defined in PhpDoc
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return string|null class where found
     */
    private function addViaPhpDocInherit(Abstraction $abs)
    {
        $inheritedFrom = null;
        $reflector = $abs['reflector'];
        while ($reflector = $reflector->getParentClass()) {
            $parsed = $this->helper->getPhpDoc($reflector);
            $tagIntersect = \array_intersect_key($parsed, $this->magicPhpDocTags);
            if (!$tagIntersect) {
                continue;
            }
            // $haveMagic = true;
            $inheritedFrom = $reflector->getName();
            $abs['phpDoc'] = \array_merge(
                $abs['phpDoc'],
                $tagIntersect
            );
            break;
        }
        return $inheritedFrom;
    }

    /**
     * Iterate over PhpDoc's magic properties & add to abstrction
     *
     * @param Abstraction $abs           Object Abstraction instance
     * @param string|null $inheritedFrom Where the magic properties were found
     *
     * @return void
     */
    private function addViaPhpDocIter(Abstraction $abs, $inheritedFrom)
    {
        $properties = $abs['properties'];
        $tags = \array_intersect_key($this->magicPhpDocTags, $abs['phpDoc']);
        foreach ($tags as $tag => $vis) {
            foreach ($abs['phpDoc'][$tag] as $phpDocProp) {
                $name = $phpDocProp['name'];
                $properties[$name] = $this->buildPropViaPhpDoc($abs, $phpDocProp, $inheritedFrom, $vis);
            }
            unset($abs['phpDoc'][$tag]);
        }
        $abs['properties'] = $properties;
    }

    /**
     * Adds properties to abstraction via reflection
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    private function addViaRef(Abstraction $abs)
    {
        $refObject = $abs['reflector'];
        /*
            We trace our ancestory to learn where properties are inherited from
        */
        while ($refObject) {
            $className = $refObject->getName();
            $properties = $refObject->getProperties();
            while ($properties) {
                $refProperty = \array_shift($properties);
                $name = $refProperty->getName();
                if (isset($abs['properties'][$name])) {
                    // already have info... we're in an ancestor
                    $abs['properties'][$name]['overrides'] = $this->propOverrides(
                        $refProperty,
                        $abs['properties'][$name],
                        $className
                    );
                    $abs['properties'][$name]['originallyDeclared'] = $className;
                    continue;
                }
                $abs['properties'][$name] = $this->buildPropViaRef($abs, $refProperty);
            }
            $refObject = $refObject->getParentClass();
        }
    }

    /**
     * Build property info from parsed PhpDoc values
     *
     * @param Abstraction $abs           Object Abstraction instance
     * @param array       $phpDocProp    parsed property docblock tag
     * @param string      $inheritedFrom className
     * @param string      $vis           prop visibility]
     *
     * @return array
     */
    private function buildPropViaPhpDoc(Abstraction $abs, $phpDocProp, $inheritedFrom, $vis)
    {
        $name = $phpDocProp['name'];
        $existing = isset($abs['properties'][$name])
            ? $abs['properties'][$name]
            : null;
        return \array_merge(
            $existing ?: self::$basePropInfo,
            array(
                'desc' => $phpDocProp['desc'],
                'type' => $this->helper->resolvePhpDocType($phpDocProp['type'], $abs),
                'inheritedFrom' => $inheritedFrom,
                'visibility' => $existing
                    ? array($existing['visibility'], $vis)
                    : $vis,
            )
        );
    }

    /**
     * Build property info via reflection
     *
     * @param Abstraction        $abs         Object Abstraction instance
     * @param ReflectionProperty $refProperty ReflectionProperty instance
     *
     * @return array
     */
    private function buildPropViaRef(Abstraction $abs, ReflectionProperty $refProperty)
    {
        $refProperty->setAccessible(true); // only accessible via reflection
        $phpDoc = $this->helper->getPhpDocVar($refProperty); // phpDocVar
        /*
            getDeclaringClass returns "LAST-declared/overriden"
        */
        $declaringClassName = $refProperty->getDeclaringClass()->getName();
        $propInfo = static::buildPropValues(array(
            'attributes' => $abs['cfgFlags'] & AbstractObject::PROP_ATTRIBUTE_COLLECT
                ? $this->helper->getAttributes($refProperty)
                : array(),
            'desc' => $phpDoc['desc'],
            'inheritedFrom' => $declaringClassName !== $abs['className']
                ? $declaringClassName
                : null,
            'isPromoted' =>  PHP_VERSION_ID >= 80000
                ? $refProperty->isPromoted()
                : false,
            'isReadOnly' => PHP_VERSION_ID >= 80100
                ? $refProperty->isReadOnly()
                : false,
            'isStatic' => $refProperty->isStatic(),
            'type' => $this->getPropType($phpDoc['type'], $refProperty),
            'visibility' => $this->helper->getVisibility($refProperty),
        ));
        if ($abs['collectPropertyValues']) {
            $propInfo = $this->getPropValue($propInfo, $abs, $refProperty);
        }
        return $propInfo;
    }

    /**
     * "Crate" property values
     *
     * @param Abstraction $abs Object Abstraction instance
     *
     * @return void
     */
    private function crate(Abstraction $abs)
    {
        $properties = $this->abs['properties'];
        $phpDocCollect = $abs['cfgFlags'] & AbstractObject::PHPDOC_COLLECT;
        foreach ($properties as $name => $info) {
            $info['value'] = $this->abstracter->crate($info['value'], $abs['debugMethod'], $abs['hist']);
            if (!$phpDocCollect) {
                $info['desc'] = null;
            }
            $properties[$name] = $info;
        }
        $abs['properties'] = $properties;
    }

    /**
     * Get Property's type
     * Priority given to phpDoc type, followed by declared type (PHP 7.4)
     *
     * @param string             $phpDocType  Type specified in phpDoc block
     * @param ReflectionProperty $refProperty ReflectionProperty instance
     *
     * @return string|null
     */
    private function getPropType($phpDocType, ReflectionProperty $refProperty)
    {
        $type = $this->helper->resolvePhpDocType($phpDocType, $this->abs);
        if ($type !== null) {
            return $type;
        }
        return PHP_VERSION_ID >= 70400
            ? $this->helper->getTypeString($refProperty->getType())
            : null;
    }

    /**
     * Set 'value' and 'valueFrom' values
     *
     * @param array              $propInfo    propInfo array
     * @param Abstraction        $abs         Object Abstraction instance
     * @param ReflectionProperty $refProperty ReflectionProperty
     *
     * @return array updated propInfo
     */
    private function getPropValue($propInfo, Abstraction $abs, ReflectionProperty $refProperty)
    {
        $propName = $refProperty->getName();
        if (\array_key_exists($propName, $abs['propertyOverrideValues'])) {
            $propInfo['valueFrom'] = 'debug';
            $value = $abs['propertyOverrideValues'][$propName];
            if (\is_array($value) && \array_intersect_key($value, static::$basePropInfo)) {
                return \array_merge($propInfo, $value);
            }
            $propInfo['value'] = $value;
            return $propInfo;
        }
        $obj = $abs->getSubject();
        $isInstance = \is_object($obj);
        if ($isInstance) {
            $isInitialized = PHP_VERSION_ID < 70400 || $refProperty->isInitialized($obj);
            $propInfo['value'] = $isInitialized
                ? $refProperty->getValue($obj)
                : Abstracter::UNDEFINED;  // value won't be displayed
        }
        return $propInfo;
    }

    /**
     * Check if a Dom* class  where properties aren't avail to reflection
     *
     * @param object $obj object to check
     *
     * @return bool
     */
    private function isDomObj($obj)
    {
        return $obj instanceof \DOMNode || $obj instanceof \DOMNodeList;
    }

    /**
     * Determine propInfo['overrides'] value
     *
     * This is the classname of previous ancestor where property is defined
     *
     * @param ReflectionProperty $refProperty Reflection Property
     * @param array              $propInfo    Property Info
     * @param string             $className   className of object being inspected
     *
     * @return string|null
     */
    private function propOverrides(ReflectionProperty $refProperty, $propInfo, $className)
    {
        if (
            empty($propInfo['overrides'])
            && empty($propInfo['inheritedFrom'])
            && $refProperty->getDeclaringClass()->getName() === $className
        ) {
            return $className;
        }
        return null;
    }
}