bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Dump/Html/HtmlString.php

Summary

Maintainability
A
25 mins
Test Coverage
A
95%
<?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-2024 Brad Kent
 * @since     3.0b1
 */

namespace bdk\Debug\Dump\Html;

use bdk\Debug;
use bdk\Debug\Abstraction\Abstraction;
use bdk\Debug\Abstraction\Type;
use bdk\Debug\Dump\Html\HtmlStringEncoded;
use bdk\Debug\Dump\Html\Value as ValDumper;
use bdk\Debug\Utility\Utf8;
use Closure;
use RuntimeException;

/**
 * Output value with HTML markup
 *
 * @property-read HtmlStringBinary  $binary
 * @property-read HtmlStringEncoded $encoded
 */
class HtmlString
{
    /** @var Debug */
    public $debug;

    /** @var bool */
    public $detectFiles = false;

    /** @var ValDumper */
    public $valDumper;

    /** @var array<string,mixed> */
    protected $lazy = array(
        'binary' => null,
        'encoded' => null,
    );

    /**
     * Constructor
     *
     * @param ValDumper $valDumper Dump\Html\Value instance
     */
    public function __construct(ValDumper $valDumper)
    {
        $this->debug = $valDumper->debug;
        $this->valDumper = $valDumper;
    }

    /**
     * Magic method to get inaccessible / undefined properties
     * Lazy load child classes
     *
     * @param string $property property name
     *
     * @return mixed property value
     *
     * @throws RuntimeException if no getter defined
     */
    public function __get($property)
    {
        if (isset($this->lazy[$property])) {
            return $this->lazy[$property];
        }
        $getter = 'get' . \ucfirst($property);
        if (!\method_exists($this, $getter)) {
            throw new RuntimeException('Access to undefined property: ' . __CLASS__ . '::' . $property);
        }
        $val = $this->{$getter}();
        $this->lazy[$property] = $val;
        return $val;
    }

    /**
     * Dump string
     *
     * @param string           $val string value
     * @param Abstraction|null $abs (optional) full abstraction
     *
     * @return string
     */
    public function dump($val, $abs = null)
    {
        $this->debug->utility->assertType($abs, 'bdk\Debug\Abstraction\Abstraction');

        if (\is_numeric($val)) {
            $this->valDumper->checkTimestamp($val, $abs);
        }
        $return = $abs
            ? $this->dumpAbs($abs)
            : $this->doDump($val);
        if ($this->detectFiles && $this->debug->utility->isFile($val)) {
            $this->valDumper->optionSet('attribs.data-file', true);
        }
        if (!$this->valDumper->optionGet('addQuotes')) {
            $this->valDumper->optionSet('attribs.class.__push__', 'no-quotes');
        }
        return $return;
    }

    /**
     * Dump with min markup
     *
     * @param mixed $val  string value
     * @param array $opts $options passed to dump
     *
     * @return string
     */
    public function dumpAsSubstitution($val, $opts)
    {
        $isBinary = $val instanceof Abstraction && $val['typeMore'] === Type::TYPE_STRING_BINARY;
        if ($isBinary) {
            $val['brief'] = true;
            return $this->binary->dump($val);
        }
        // we do NOT wrap in <span>...  log('<a href="%s">link</a>', $url);
        $opts['tagName'] = null;
        return $this->valDumper->dump($val, $opts);
    }

    /**
     * Is value encoded (ie base64, json, or serialized)
     *
     * @param mixed $val string value (or abstraction)
     *
     * @return bool
     */
    public function isEncoded($val)
    {
        $typesEncoded = [
            Type::TYPE_STRING_BASE64,
            Type::TYPE_STRING_JSON,
            Type::TYPE_STRING_SERIALIZED,
        ];
        return $val instanceof Abstraction && \in_array($val['typeMore'], $typesEncoded, true);
    }

    /**
     * Add whitespace markup
     *
     * @param string $str string which to add whitespace html markup
     *
     * @return string
     */
    public function visualWhiteSpace($str)
    {
        // display \r, \n, & \t
        $str = \preg_replace_callback('/(\r\n|\r|\n)/', static function ($matches) {
            $search = ["\r", "\n"];
            $replace = ['<span class="ws_r"></span>', '<span class="ws_n"></span>' . "\n"];
            return \str_replace($search, $replace, $matches[1]);
        }, $str);
        return \str_replace("\t", '<span class="ws_t">' . "\t" . '</span>', $str);
    }

    /**
     * Dump string encapsulated by Abstraction
     *
     * @param Abstraction $abs String Abstraction
     *
     * @return string
     */
    private function dumpAbs(Abstraction $abs)
    {
        if ($abs['strlen'] === null) {
            $abs['strlen'] = \strlen($abs['value']);
        }
        if ($abs['strlenValue'] === null) {
            $abs['strlenValue'] = $abs['strlen'];
        }
        if ($abs['typeMore'] === Type::TYPE_STRING_BINARY) {
            return $this->binary->dump($abs);
        }
        if ($this->isEncoded($abs)) {
            return $this->encoded->dump($abs);
        }
        if ($abs['prettified']) {
            $this->valDumper->optionSet('visualWhiteSpace', false);
            $this->valDumper->optionSet('postDump', $this->buildPrettifiedPostDump($abs));
        }
        $val = $this->doDump((string) $abs);
        $strLenDiff = $abs['strlen'] - $abs['strlenValue'];
        if ($strLenDiff) {
            $val .= '<span class="maxlen">&hellip; ' . $strLenDiff . ' more bytes (not logged)</span>';
        }
        return $val;
    }

    /**
     * Build replacement for control character
     *
     * @param array<string,string|null> $info Character info
     *
     * @return string
     */
    private function buildHighlightReplacementControl(array $info)
    {
        return $this->debug->html->buildTag(
            'span',
            array(
                'class' => $info['class'],
                'data-abbr' => $info['abbr'],
                'title' => \implode(': ', \array_filter([
                    '\\x' . \str_pad(\dechex(\ord($info['char'])), 2, '0', STR_PAD_LEFT),
                    $info['desc'],
                ])),
            ),
            $info['replaceWith']
        );
    }

    /**
     * Build replacement for unicode character
     *
     * @param array<string,string|null> $info Character info
     *
     * @return string
     */
    private function buildHighlightReplacementOther(array $info)
    {
        $codePoint = $info['codePoint'] ?: \dechex(Utf8::ord($info['char']));
        return $this->debug->html->buildTag(
            'span',
            array(
                'class' => $info['class'],
                'data-code-point' => $codePoint,
                'title' => \implode(': ', \array_filter([
                    'U-' . $codePoint,
                    $info['desc'],
                ])),
            ),
            $info['replaceWith']
        );
    }

    /**
     * Add "prettified" tag to prettified value
     *
     * @param Abstraction $abs String abstraction
     *
     * @return Closure
     */
    private function buildPrettifiedPostDump(Abstraction $abs)
    {
        return function ($dumped, $opts) use ($abs) {
            if ($abs['prettifiedTag'] === false) {
                return $dumped;
            }
            $tagName = 'span';
            if ($opts['tagName'] === 'td') {
                $tagName = 'td';
                $parsed = $this->debug->html->parseTag($dumped);
                $dumped = $this->debug->html->buildTag('span', $parsed['attribs'], $parsed['innerhtml']);
            }
            return $this->debug->html->buildTag(
                $tagName,
                \array_filter(array(
                    'class' => 'value-container',
                    'data-type' => $abs['type'],
                    'data-type-more' => $abs['typeMore'],
                )),
                '<span class="prettified">(prettified)</span> ' . $dumped
            );
        };
    }

    /**
     * Sanitize and dump string.
     *
     * @param string $val string value to dump
     *
     * @return string
     */
    private function doDump($val)
    {
        $opts = $this->valDumper->optionGet();
        if ($opts['sanitize']) {
            $val = \htmlspecialchars($val);
        }
        if ($opts['charHighlight']) {
            $val = $this->highlightChars($val);
        }
        if ($opts['visualWhiteSpace']) {
            $val = $this->visualWhiteSpace($val);
        }
        return $val;
    }

    /**
     * lazy load HtmlStringEncoded instance
     *
     * @return HtmlStringBinary
     */
    protected function getBinary()
    {
        if (isset($this->lazy['binary'])) {
            return $this->lazy['binary'];
        }
        return new HtmlStringBinary($this);
    }

    /**
     * lazy load HtmlStringEncoded instance
     *
     * @return HtmlStringEncoded
     */
    protected function getEncoded()
    {
        if (isset($this->lazy['encoded'])) {
            return $this->lazy['encoded'];
        }
        return new HtmlStringEncoded($this);
    }

    /**
     * Highlight confusable and other characters
     *
     * @param string $str HTML String to update
     *
     * @return string
     */
    private function highlightChars($str)
    {
        $chars = $this->valDumper->findChars($str);
        $charInfo = \array_intersect_key($this->valDumper->charData, \array_flip($chars));
        foreach ($charInfo as $char => $info) {
            $info = \array_merge(array(
                'char' => $char,
                'class' => 'unicode',
                'codePoint' => null,
                'desc' => '',
                'replaceWith' => $char,
            ), $info);
            $replacement = \ord($char[0]) < 0x80
                ? $this->buildHighlightReplacementControl($info)
                : $this->buildHighlightReplacementOther($info);
            $str = \str_replace($char, $replacement, $str);
        }
        return $str;
    }
}