diff --git a/app/modules/VarDumper/Application/Dump/BodyInterface.php b/app/modules/VarDumper/Application/Dump/BodyInterface.php new file mode 100644 index 00000000..a5a1853b --- /dev/null +++ b/app/modules/VarDumper/Application/Dump/BodyInterface.php @@ -0,0 +1,12 @@ +html = $value; + \preg_match_all('/sf-dump-\d+/', $value, $matches); + $this->id = $matches[0][0]; + } + + public function getId(): string + { + return $this->id; + } + + public function getType(): string + { + return 'html'; + } + + public function getValue(): string + { + return $this->html; + } + + public function __toString(): string + { + return $this->html; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } +} diff --git a/app/modules/VarDumper/Application/Dump/HtmlDumper.php b/app/modules/VarDumper/Application/Dump/HtmlDumper.php new file mode 100644 index 00000000..ea3d00e9 --- /dev/null +++ b/app/modules/VarDumper/Application/Dump/HtmlDumper.php @@ -0,0 +1,281 @@ +'; + protected string $dumpSuffix = ''; + protected string $dumpId = 'sf-dump'; + protected $colors = true; + protected int $lastDepth = -1; + + private array $displayOptions = [ + 'maxDepth' => 1, + 'maxStringLength' => 160, + 'fileLinkFormat' => null, + ]; + private array $extraDisplayOptions = []; + + public function __construct(DumpIdGeneratorInterface $generator) + { + AbstractDumper::__construct(null, null, 0); + $this->dumpId = $generator->generate(); + $this->displayOptions['fileLinkFormat'] = \ini_get('xdebug.file_link_format') ?: get_cfg_var( + 'xdebug.file_link_format', + ); + } + + public function setStyles(array $styles): void + { + $this->styles = $styles + $this->styles; + } + + /** + * Configures display options. + * + * @param array $displayOptions A map of display options to customize the behavior + * + * @return void + */ + public function setDisplayOptions(array $displayOptions) + { + $this->displayOptions = $displayOptions + $this->displayOptions; + } + + public function dump(Data $data, $output = null, array $extraDisplayOptions = []): ?string + { + $this->extraDisplayOptions = $extraDisplayOptions; + $result = parent::dump($data, $output); + $this->dumpId = 'sf-dump-' . mt_rand(); + + return $result; + } + + /** + * @return void + */ + public function dumpString(Cursor $cursor, string $str, bool $bin, int $cut) + { + if ('' === $str && isset($cursor->attr['img-data'], $cursor->attr['content-type'])) { + $this->dumpKey($cursor); + $this->line .= $this->style('default', $cursor->attr['img-size'] ?? '', []); + $this->line .= $cursor->depth >= $this->displayOptions['maxDepth'] ? ' ' : ' '; + $this->endValue($cursor); + $this->line .= $this->indentPad; + $this->line .= sprintf( + '', + $cursor->attr['content-type'], + base64_encode($cursor->attr['img-data']), + ); + $this->endValue($cursor); + } else { + parent::dumpString($cursor, $str, $bin, $cut); + } + } + + /** + * @return void + */ + public function enterHash(Cursor $cursor, int $type, string|int|null $class, bool $hasChild) + { + if (Cursor::HASH_OBJECT === $type) { + $cursor->attr['depth'] = $cursor->depth; + } + parent::enterHash($cursor, $type, $class, false); + + if ($cursor->skipChildren || $cursor->depth >= $this->displayOptions['maxDepth']) { + $cursor->skipChildren = false; + $eol = ' class=sf-dump-compact>'; + } else { + $this->expandNextHash = false; + $eol = ' class=sf-dump-expanded>'; + } + + if ($hasChild) { + $this->line .= 'dumpId, $r); + } + $this->line .= $eol; + $this->dumpLine($cursor->depth); + } + } + + /** + * @return void + */ + public function leaveHash(Cursor $cursor, int $type, string|int|null $class, bool $hasChild, int $cut) + { + $this->dumpEllipsis($cursor, $hasChild, $cut); + if ($hasChild) { + $this->line .= ''; + } + parent::leaveHash($cursor, $type, $class, $hasChild, 0); + } + + protected function style(string $style, string $value, array $attr = []): string + { + if ('' === $value && ('label' !== $style || !isset($attr['file']) && !isset($attr['href']))) { + return ''; + } + + $v = esc($value); + + if ('ref' === $style) { + if (empty($attr['count'])) { + return sprintf('%s', $v); + } + $r = ('#' !== $v[0] ? 1 - ('@' !== $v[0]) : 2) . substr($value, 1); + + return sprintf( + '%s', + $this->dumpId, + $r, + 1 + $attr['count'], + $v, + ); + } + + if ('const' === $style && isset($attr['value'])) { + $style .= sprintf( + ' title="%s"', + esc(\is_scalar($attr['value']) ? $attr['value'] : json_encode($attr['value'])), + ); + } elseif ('note' === $style && 0 < ($attr['depth'] ?? 0) && false !== $c = strrpos($value, '\\')) { + $style .= ' title=""'; + $attr += [ + 'ellipsis' => \strlen($value) - $c, + 'ellipsis-type' => 'note', + 'ellipsis-tail' => 1, + ]; + } + + if (isset($attr['ellipsis'])) { + $class = 'sf-dump-ellipsis'; + if (isset($attr['ellipsis-type'])) { + $class = sprintf('"%s sf-dump-ellipsis-%s"', $class, $attr['ellipsis-type']); + } + $label = esc(substr($value, -$attr['ellipsis'])); + $style = str_replace(' title="', " title=\"$v\n", $style); + $v = sprintf('%s', $class, substr($v, 0, -\strlen($label))); + + if (!empty($attr['ellipsis-tail'])) { + $tail = \strlen(esc(substr($value, -$attr['ellipsis'], $attr['ellipsis-tail']))); + $v .= sprintf('%s%s', $class, substr($label, 0, $tail), substr($label, $tail)); + } else { + $v .= $label; + } + } + + $map = static::$controlCharsMap; + $v = "" . preg_replace_callback(static::$controlCharsRx, function ($c) use ($map) { + $s = $b = ''; + }, $v) . ''; + + if (!($attr['binary'] ?? false)) { + $v = preg_replace_callback(static::$unicodeCharsRx, function ($c) { + return '\u{' . strtoupper(dechex(mb_ord($c[0]))) . '}'; + }, $v); + } + + if (isset($attr['file']) && $href = $this->getSourceLink($attr['file'], $attr['line'] ?? 0)) { + $attr['href'] = $href; + } + if (isset($attr['href'])) { + if ('label' === $style) { + $v .= '^'; + } + $target = isset($attr['file']) ? '' : ' target="_blank"'; + $v = sprintf( + '%s', + esc($this->utf8Encode($attr['href'])), + $target, + $v, + ); + } + if (isset($attr['lang'])) { + $v = sprintf('%s', esc($attr['lang']), $v); + } + if ('label' === $style) { + $v .= ' '; + } + + return $v; + } + + /** + * @return void + */ + protected function dumpLine(int $depth, bool $endOfValue = false) + { + if (-1 === $this->lastDepth) { + $this->line = sprintf($this->dumpPrefix, $this->dumpId, $this->indentPad) . $this->line; + } + + if (-1 === $depth) { + $args = ['"' . $this->dumpId . '"']; + if ($this->extraDisplayOptions) { + $args[] = json_encode($this->extraDisplayOptions, \JSON_FORCE_OBJECT); + } + // Replace is for BC + $this->line .= sprintf(str_replace('"%s"', '%s', $this->dumpSuffix), implode(', ', $args)); + } + $this->lastDepth = $depth; + + $this->line = mb_encode_numericentity($this->line, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8'); + + if (-1 === $depth) { + AbstractDumper::dumpLine(0); + } + AbstractDumper::dumpLine($depth); + } + + private function getSourceLink(string $file, int $line): string|false + { + $options = $this->extraDisplayOptions + $this->displayOptions; + + if ($fmt = $options['fileLinkFormat']) { + return \is_string($fmt) ? strtr($fmt, ['%f' => $file, '%l' => $line]) : $fmt->format($file, $line); + } + + return false; + } +} + +function esc(string $str): string +{ + return htmlspecialchars($str, \ENT_QUOTES, 'UTF-8'); +} + diff --git a/app/modules/VarDumper/Application/Dump/MtRandDumpIdGenerator.php b/app/modules/VarDumper/Application/Dump/MtRandDumpIdGenerator.php new file mode 100644 index 00000000..1fcd750e --- /dev/null +++ b/app/modules/VarDumper/Application/Dump/MtRandDumpIdGenerator.php @@ -0,0 +1,13 @@ +type; + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } +} diff --git a/app/modules/VarDumper/Application/VarDumperBootloader.php b/app/modules/VarDumper/Application/VarDumperBootloader.php new file mode 100644 index 00000000..4ee91539 --- /dev/null +++ b/app/modules/VarDumper/Application/VarDumperBootloader.php @@ -0,0 +1,19 @@ + MtRandDumpIdGenerator::class, + ]; + } +} diff --git a/app/modules/VarDumper/Interfaces/TCP/Service.php b/app/modules/VarDumper/Interfaces/TCP/Service.php index b655a4e3..266eb880 100644 --- a/app/modules/VarDumper/Interfaces/TCP/Service.php +++ b/app/modules/VarDumper/Interfaces/TCP/Service.php @@ -5,7 +5,12 @@ namespace Modules\VarDumper\Interfaces\TCP; use App\Application\Commands\HandleReceivedEvent; +use Modules\VarDumper\Application\Dump\BodyInterface; +use Modules\VarDumper\Application\Dump\DumpIdGeneratorInterface; +use Modules\VarDumper\Application\Dump\HtmlBody; +use Modules\VarDumper\Application\Dump\HtmlDumper; use Modules\VarDumper\Application\Dump\MessageParser; +use Modules\VarDumper\Application\Dump\PrimitiveBody; use Spiral\Cqrs\CommandBusInterface; use Spiral\RoadRunner\Tcp\Request; use Spiral\RoadRunner\Tcp\TcpEvent; @@ -13,12 +18,12 @@ use Spiral\RoadRunnerBridge\Tcp\Response\ResponseInterface; use Spiral\RoadRunnerBridge\Tcp\Service\ServiceInterface; use Symfony\Component\VarDumper\Cloner\Data; -use Symfony\Component\VarDumper\Dumper\HtmlDumper; final readonly class Service implements ServiceInterface { public function __construct( private CommandBusInterface $commandBus, + private DumpIdGeneratorInterface $dumpId, ) { } @@ -49,19 +54,26 @@ private function fireEvent(array $payload): void 'value' => $this->convertToPrimitive($payload[0]), ], 'context' => $payload[1], - ] - ) + ], + ), ); } - private function convertToPrimitive(Data $data): string|null + private function convertToPrimitive(Data $data): BodyInterface|null { if (\in_array($data->getType(), ['string', 'boolean'])) { - return (string)$data->getValue(); + return new PrimitiveBody( + type: $data->getType(), + value: $data->getValue(), + ); } - $dumper = new HtmlDumper(); + $dumper = new HtmlDumper( + generator: $this->dumpId, + ); - return $dumper->dump($data, true); + return new HtmlBody( + value: $dumper->dump($data, true), + ); } } diff --git a/app/src/Application/Kernel.php b/app/src/Application/Kernel.php index 42727284..cf58cc1f 100644 --- a/app/src/Application/Kernel.php +++ b/app/src/Application/Kernel.php @@ -15,6 +15,7 @@ use Modules\Ray\Application\RayBootloader; use Modules\HttpDumps\Application\HttpDumpsBootloader; use Modules\Sentry\Application\SentryBootloader; +use Modules\VarDumper\Application\VarDumperBootloader; use Spiral\Boot\Bootloader\CoreBootloader; use Spiral\Bootloader as Framework; use Spiral\Cqrs\Bootloader\CqrsBootloader; @@ -103,6 +104,7 @@ protected function defineBootloaders(): array AppBootloader::class, InspectorBootloader::class, SentryBootloader::class, + VarDumperBootloader::class, RayBootloader::class, HttpDumpsBootloader::class, ProfilerBootloader::class, diff --git a/phpunit.xml b/phpunit.xml index 42bb75ba..c99b4e28 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + diff --git a/tests/Feature/Interfaces/TCP/VarDumper/SymfonyV7Test.php b/tests/Feature/Interfaces/TCP/VarDumper/SymfonyV7Test.php index f22baa23..f37fba7b 100644 --- a/tests/Feature/Interfaces/TCP/VarDumper/SymfonyV7Test.php +++ b/tests/Feature/Interfaces/TCP/VarDumper/SymfonyV7Test.php @@ -4,9 +4,12 @@ namespace Tests\Feature\Interfaces\TCP\VarDumper; +use Modules\VarDumper\Application\Dump\DumpIdGeneratorInterface; use Modules\VarDumper\Interfaces\TCP\Service; use Spiral\RoadRunner\Tcp\Request; use Spiral\RoadRunner\Tcp\TcpEvent; +use Symfony\Component\VarDumper\Caster\ReflectionCaster; +use Symfony\Component\VarDumper\Cloner\VarCloner; use Tests\Feature\Interfaces\TCP\TCPTestCase; final class SymfonyV7Test extends TCPTestCase @@ -39,6 +42,51 @@ public function testSendDump(): void $this->assertNotEmpty($data['data']['uuid']); $this->assertNotEmpty($data['data']['timestamp']); + return true; + }); + } + + public function testSendObjectDump(): void + { + $generator = $this->mockContainer(DumpIdGeneratorInterface::class); + $generator->shouldReceive('generate')->andReturn('sf-dump-730421088'); + + $cloner = new VarCloner(); + $cloner->addCasters(ReflectionCaster::UNSET_CLOSURE_FILE_INFO); + $data = $cloner->cloneVar((object)['type' => 'string', 'value' => 'foo']); + + $payload = \base64_encode(\serialize([$data, []])) . "\n"; + + $service = $this->get(Service::class); + + $service->handle( + new Request( + remoteAddr: '127.0.0.1', + event: TcpEvent::Data, + body: $payload, + connectionUuid: (string)$this->randomUuid(), + server: 'local', + ), + ); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('var-dump', $data['data']['type']); + + $this->assertSame([ + 'type' => 'stdClass', + 'value' => <<<'HTML' +
{#2296
+  +"type": "string"
+  +"value": "foo"
+}
+
+ +HTML, + ], $data['data']['payload']['payload']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); return true; });