diff --git a/src/Symfony/Bundle/DebugBundle/CHANGELOG.md b/src/Symfony/Bundle/DebugBundle/CHANGELOG.md new file mode 100644 index 000000000000..685dd1d0794f --- /dev/null +++ b/src/Symfony/Bundle/DebugBundle/CHANGELOG.md @@ -0,0 +1,8 @@ +CHANGELOG +========= + +4.1.0 +----- + + * Added the `server:dump` command to run a server collecting and displaying + dumps on a single place with multiple formats support diff --git a/src/Symfony/Bundle/DebugBundle/Command/ServerDumpPlaceholderCommand.php b/src/Symfony/Bundle/DebugBundle/Command/ServerDumpPlaceholderCommand.php new file mode 100644 index 000000000000..8b314a8c929e --- /dev/null +++ b/src/Symfony/Bundle/DebugBundle/Command/ServerDumpPlaceholderCommand.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\DebugBundle\Command; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarDumper\Command\ServerDumpCommand; +use Symfony\Component\VarDumper\Server\DumpServer; + +/** + * A placeholder command easing VarDumper server discovery. + * + * @author Maxime Steinhausser + * + * @internal + */ +class ServerDumpPlaceholderCommand extends ServerDumpCommand +{ + public function __construct(DumpServer $server = null, array $descriptors = array()) + { + parent::__construct(new class() extends DumpServer { + public function __construct() + { + } + }, $descriptors); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + (new SymfonyStyle($input, $output))->getErrorStyle()->warning('In order to use the VarDumper server, set the "debug.dump_destination" config option to "tcp://%env(VAR_DUMPER_SERVER)%"'); + + return 8; + } +} diff --git a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php index 4af24cd48839..cbb27c479fd0 100644 --- a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php @@ -48,7 +48,7 @@ public function getConfigTreeBuilder() ->end() ->scalarNode('dump_destination') ->info('A stream URL where dumps should be written to') - ->example('php://stderr') + ->example('php://stderr, or tcp://%env(VAR_DUMPER_SERVER)% when using the "server:dump" command') ->defaultNull() ->end() ->end() diff --git a/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php b/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php index 835d82366402..a81c495970b1 100644 --- a/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php +++ b/src/Symfony/Bundle/DebugBundle/DependencyInjection/DebugExtension.php @@ -11,11 +11,13 @@ namespace Symfony\Bundle\DebugBundle\DependencyInjection; +use Symfony\Bundle\DebugBundle\Command\ServerDumpPlaceholderCommand; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\VarDumper\Dumper\ServerDumper; /** * DebugExtension. @@ -40,7 +42,23 @@ public function load(array $configs, ContainerBuilder $container) ->addMethodCall('setMinDepth', array($config['min_depth'])) ->addMethodCall('setMaxString', array($config['max_string_length'])); - if (null !== $config['dump_destination']) { + if (null === $config['dump_destination']) { + //no-op + } elseif (0 === strpos($config['dump_destination'], 'tcp://')) { + $serverDumperHost = $config['dump_destination']; + $container->getDefinition('debug.dump_listener') + ->replaceArgument(1, new Reference('var_dumper.server_dumper')) + ; + $container->getDefinition('data_collector.dump') + ->replaceArgument(4, new Reference('var_dumper.server_dumper')) + ; + $container->getDefinition('var_dumper.dump_server') + ->replaceArgument(0, $serverDumperHost) + ; + $container->getDefinition('var_dumper.server_dumper') + ->replaceArgument(0, $serverDumperHost) + ; + } else { $container->getDefinition('var_dumper.cli_dumper') ->replaceArgument(0, $config['dump_destination']) ; @@ -48,6 +66,13 @@ public function load(array $configs, ContainerBuilder $container) ->replaceArgument(4, new Reference('var_dumper.cli_dumper')) ; } + + if (!isset($serverDumperHost)) { + $container->getDefinition('var_dumper.command.server_dump')->setClass(ServerDumpPlaceholderCommand::class); + if (!class_exists(ServerDumper::class)) { + $container->removeDefinition('var_dumper.command.server_dump'); + } + } } /** diff --git a/src/Symfony/Bundle/DebugBundle/Resources/config/services.xml b/src/Symfony/Bundle/DebugBundle/Resources/config/services.xml index 7e276dafab5d..a0bbde8d3d8d 100644 --- a/src/Symfony/Bundle/DebugBundle/Resources/config/services.xml +++ b/src/Symfony/Bundle/DebugBundle/Resources/config/services.xml @@ -4,6 +4,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + 127.0.0.1:9912 + + @@ -19,7 +23,7 @@ %kernel.charset% - null + null @@ -34,6 +38,7 @@ %kernel.charset% 0 + null %kernel.charset% @@ -44,5 +49,50 @@ + + + null + + + + + %kernel.charset% + %kernel.project_dir% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php index e0297ea39596..aca9e005fe1f 100644 --- a/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php +++ b/src/Symfony/Component/HttpKernel/DataCollector/DumpDataCollector.php @@ -18,9 +18,10 @@ use Symfony\Component\VarDumper\Cloner\Data; use Symfony\Component\VarDumper\Cloner\VarCloner; use Symfony\Component\VarDumper\Dumper\CliDumper; +use Symfony\Component\VarDumper\Dumper\ContextProvider\SourceContextProvider; use Symfony\Component\VarDumper\Dumper\HtmlDumper; use Symfony\Component\VarDumper\Dumper\DataDumperInterface; -use Twig\Template; +use Symfony\Component\VarDumper\Dumper\ServerDumper; /** * @author Nicolas Grekas @@ -38,6 +39,7 @@ class DumpDataCollector extends DataCollector implements DataDumperInterface private $requestStack; private $dumper; private $dumperIsInjected; + private $sourceContextProvider; public function __construct(Stopwatch $stopwatch = null, $fileLinkFormat = null, string $charset = null, RequestStack $requestStack = null, DataDumperInterface $dumper = null) { @@ -55,6 +57,8 @@ public function __construct(Stopwatch $stopwatch = null, $fileLinkFormat = null, &$this->isCollected, &$this->clonesCount, ); + + $this->sourceContextProvider = $dumper instanceof ServerDumper && isset($dumper->getContextProviders()['source']) ? $dumper->getContextProviders()['source'] : new SourceContextProvider($this->charset); } public function __clone() @@ -71,61 +75,10 @@ public function dump(Data $data) $this->isCollected = false; } - $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 7); - - $file = $trace[0]['file']; - $line = $trace[0]['line']; - $name = false; - $fileExcerpt = false; - - for ($i = 1; $i < 7; ++$i) { - if (isset($trace[$i]['class'], $trace[$i]['function']) - && 'dump' === $trace[$i]['function'] - && 'Symfony\Component\VarDumper\VarDumper' === $trace[$i]['class'] - ) { - $file = $trace[$i]['file']; - $line = $trace[$i]['line']; - - while (++$i < 7) { - if (isset($trace[$i]['function'], $trace[$i]['file']) && empty($trace[$i]['class']) && 0 !== strpos($trace[$i]['function'], 'call_user_func')) { - $file = $trace[$i]['file']; - $line = $trace[$i]['line']; - - break; - } elseif (isset($trace[$i]['object']) && $trace[$i]['object'] instanceof Template) { - $template = $trace[$i]['object']; - $name = $template->getTemplateName(); - $src = method_exists($template, 'getSourceContext') ? $template->getSourceContext()->getCode() : (method_exists($template, 'getSource') ? $template->getSource() : false); - $info = $template->getDebugInfo(); - if (isset($info[$trace[$i - 1]['line']])) { - $line = $info[$trace[$i - 1]['line']]; - $file = method_exists($template, 'getSourceContext') ? $template->getSourceContext()->getPath() : null; - - if ($src) { - $src = explode("\n", $src); - $fileExcerpt = array(); - - for ($i = max($line - 3, 1), $max = min($line + 3, count($src)); $i <= $max; ++$i) { - $fileExcerpt[] = ''.$this->htmlEncode($src[$i - 1]).''; - } - - $fileExcerpt = '
    '.implode("\n", $fileExcerpt).'
'; - } - } - break; - } - } - break; - } - } - - if (false === $name) { - $name = str_replace('\\', '/', $file); - $name = substr($name, strrpos($name, '/') + 1); - } + list('name' => $name, 'file' => $file, 'line' => $line, 'file_excerpt' => $fileExcerpt) = $this->sourceContextProvider->getContext(); if ($this->dumper) { - $this->doDump($data, $name, $file, $line); + $this->doDump($this->dumper, $data, $name, $file, $line); } $this->data[] = compact('data', 'name', 'file', 'line', 'fileExcerpt'); @@ -152,14 +105,14 @@ public function collect(Request $request, Response $response, \Exception $except || false === strripos($response->getContent(), '') ) { if ($response->headers->has('Content-Type') && false !== strpos($response->headers->get('Content-Type'), 'html')) { - $this->dumper = new HtmlDumper('php://output', $this->charset); - $this->dumper->setDisplayOptions(array('fileLinkFormat' => $this->fileLinkFormat)); + $dumper = new HtmlDumper('php://output', $this->charset); + $dumper->setDisplayOptions(array('fileLinkFormat' => $this->fileLinkFormat)); } else { - $this->dumper = new CliDumper('php://output', $this->charset); + $dumper = new CliDumper('php://output', $this->charset); } foreach ($this->data as $dump) { - $this->doDump($dump['data'], $dump['name'], $dump['file'], $dump['line']); + $this->doDump($dumper, $dump['data'], $dump['name'], $dump['file'], $dump['line']); } } } @@ -251,15 +204,15 @@ public function __destruct() } if ('cli' !== PHP_SAPI && stripos($h[$i], 'html')) { - $this->dumper = new HtmlDumper('php://output', $this->charset); - $this->dumper->setDisplayOptions(array('fileLinkFormat' => $this->fileLinkFormat)); + $dumper = new HtmlDumper('php://output', $this->charset); + $dumper->setDisplayOptions(array('fileLinkFormat' => $this->fileLinkFormat)); } else { - $this->dumper = new CliDumper('php://output', $this->charset); + $dumper = new CliDumper('php://output', $this->charset); } foreach ($this->data as $i => $dump) { $this->data[$i] = null; - $this->doDump($dump['data'], $dump['name'], $dump['file'], $dump['line']); + $this->doDump($dumper, $dump['data'], $dump['name'], $dump['file'], $dump['line']); } $this->data = array(); @@ -267,9 +220,9 @@ public function __destruct() } } - private function doDump($data, $name, $file, $line) + private function doDump(DataDumperInterface $dumper, $data, $name, $file, $line) { - if ($this->dumper instanceof CliDumper) { + if ($dumper instanceof CliDumper) { $contextDumper = function ($name, $file, $line, $fmt) { if ($this instanceof HtmlDumper) { if ($file) { @@ -290,26 +243,12 @@ private function doDump($data, $name, $file, $line) } $this->dumpLine(0); }; - $contextDumper = $contextDumper->bindTo($this->dumper, $this->dumper); + $contextDumper = $contextDumper->bindTo($dumper, $dumper); $contextDumper($name, $file, $line, $this->fileLinkFormat); - } else { + } elseif (!$dumper instanceof ServerDumper) { $cloner = new VarCloner(); - $this->dumper->dump($cloner->cloneVar($name.' on line '.$line.':')); + $dumper->dump($cloner->cloneVar($name.' on line '.$line.':')); } - $this->dumper->dump($data); - } - - private function htmlEncode($s) - { - $html = ''; - - $dumper = new HtmlDumper(function ($line) use (&$html) { $html .= $line; }, $this->charset); - $dumper->setDumpHeader(''); - $dumper->setDumpBoundaries('', ''); - - $cloner = new VarCloner(); - $dumper->dump($cloner->cloneVar($s)); - - return substr(strip_tags($html), 1, -1); + $dumper->dump($data); } } diff --git a/src/Symfony/Component/HttpKernel/Tests/DataCollector/DumpDataCollectorTest.php b/src/Symfony/Component/HttpKernel/Tests/DataCollector/DumpDataCollectorTest.php index fc4b92b5334d..0c5fc6b6a115 100644 --- a/src/Symfony/Component/HttpKernel/Tests/DataCollector/DumpDataCollectorTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/DataCollector/DumpDataCollectorTest.php @@ -12,10 +12,11 @@ namespace Symfony\Component\HttpKernel\Tests\DataCollector; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Dumper\ServerDumper; /** * @author Nicolas Grekas @@ -55,6 +56,25 @@ public function testDump() $this->assertSame('a:2:{i:0;b:0;i:1;s:5:"UTF-8";}', $collector->serialize()); } + public function testDumpWithServerDumper() + { + $data = new Data(array(array(123))); + + // Server is up, server dumper is used + $serverDumper = $this->getMockBuilder(ServerDumper::class)->disableOriginalConstructor()->getMock(); + $serverDumper->expects($this->once())->method('dump'); + $serverDumper->method('isServerListening')->willReturn(true); + + $collector = new DumpDataCollector(null, null, null, null, $serverDumper); + $collector->dump($data); + + // Collect doesn't re-trigger dump + ob_start(); + $collector->collect(new Request(), new Response()); + $this->assertEmpty(ob_get_clean()); + $this->assertStringMatchesFormat('a:3:{i:0;a:5:{s:4:"data";%c:39:"Symfony\Component\VarDumper\Cloner\Data":%a', $collector->serialize()); + } + public function testCollectDefault() { $data = new Data(array(array(123))); diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index 22eedffe8b59..9a5c8cd33215 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -36,7 +36,7 @@ "symfony/stopwatch": "~3.4|~4.0", "symfony/templating": "~3.4|~4.0", "symfony/translation": "~3.4|~4.0", - "symfony/var-dumper": "~3.4|~4.0", + "symfony/var-dumper": "~4.1", "psr/cache": "~1.0" }, "provide": { diff --git a/src/Symfony/Component/VarDumper/CHANGELOG.md b/src/Symfony/Component/VarDumper/CHANGELOG.md index 6ece28fcb679..34d9bbb0f539 100644 --- a/src/Symfony/Component/VarDumper/CHANGELOG.md +++ b/src/Symfony/Component/VarDumper/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +4.1.0 +----- + + * added a `ServerDumper` to send serialized Data clones to a server + * added a `ServerDumpCommand` and `DumpServer` to run a server collecting + and displaying dumps on a single place with multiple formats support + * added `CliDescriptor` and `HtmlDescriptor` descriptors for `server:dump` CLI and HTML formats support + 4.0.0 ----- diff --git a/src/Symfony/Component/VarDumper/Command/Descriptor/CliDescriptor.php b/src/Symfony/Component/VarDumper/Command/Descriptor/CliDescriptor.php new file mode 100644 index 000000000000..562258fa3688 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Command/Descriptor/CliDescriptor.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Command\Descriptor; + +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Dumper\CliDumper; + +/** + * Describe collected data clones for cli output. + * + * @author Maxime Steinhausser + * + * @final + */ +class CliDescriptor implements DumpDescriptorInterface +{ + private $dumper; + private $lastIdentifier; + + public function __construct(CliDumper $dumper) + { + $this->dumper = $dumper; + } + + public function describe(OutputInterface $output, Data $data, array $context, int $clientId): void + { + $io = $output instanceof SymfonyStyle ? $output : new SymfonyStyle(new ArrayInput(array()), $output); + + $rows = array(array('date', date('r', $context['timestamp']))); + $lastIdentifier = $this->lastIdentifier; + $this->lastIdentifier = $clientId; + + $section = "Received from client #$clientId"; + if (isset($context['request'])) { + $request = $context['request']; + $this->lastIdentifier = $request['identifier']; + $section = sprintf('%s %s', $request['method'], $request['uri']); + if ($controller = $request['controller']) { + $rows[] = array('controller', $controller); + } + } elseif (isset($context['cli'])) { + $this->lastIdentifier = $context['cli']['identifier']; + $section = '$ '.$context['cli']['command_line']; + } + + if ($this->lastIdentifier !== $lastIdentifier) { + $io->section($section); + } + + if (isset($context['source'])) { + $source = $context['source']; + $rows[] = array('source', sprintf('%s on line %d', $source['name'], $source['line'])); + $file = $source['file_relative'] ?? $source['file']; + $rows[] = array('file', $file); + $fileLink = $source['file_link'] ?? null; + } + + $io->table(array(), $rows); + + if (isset($fileLink)) { + $io->writeln(array('Open source in your IDE/browser:', $fileLink)); + $io->newLine(); + } + + $this->dumper->dump($data); + $io->newLine(); + } +} diff --git a/src/Symfony/Component/VarDumper/Command/Descriptor/DumpDescriptorInterface.php b/src/Symfony/Component/VarDumper/Command/Descriptor/DumpDescriptorInterface.php new file mode 100644 index 000000000000..267d27bfaccf --- /dev/null +++ b/src/Symfony/Component/VarDumper/Command/Descriptor/DumpDescriptorInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Command\Descriptor; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * @author Maxime Steinhausser + */ +interface DumpDescriptorInterface +{ + public function describe(OutputInterface $output, Data $data, array $context, int $clientId): void; +} diff --git a/src/Symfony/Component/VarDumper/Command/Descriptor/HtmlDescriptor.php b/src/Symfony/Component/VarDumper/Command/Descriptor/HtmlDescriptor.php new file mode 100644 index 000000000000..e11d22ae3311 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Command/Descriptor/HtmlDescriptor.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Command\Descriptor; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; + +/** + * Describe collected data clones for html output. + * + * @author Maxime Steinhausser + * + * @final + */ +class HtmlDescriptor implements DumpDescriptorInterface +{ + private $dumper; + private $initialized = false; + + public function __construct(HtmlDumper $dumper) + { + $this->dumper = $dumper; + } + + public function describe(OutputInterface $output, Data $data, array $context, int $clientId): void + { + if (!$this->initialized) { + $styles = file_get_contents(__DIR__.'/../../Resources/css/htmlDescriptor.css'); + $scripts = file_get_contents(__DIR__.'/../../Resources/js/htmlDescriptor.js'); + $output->writeln(""); + $this->initialized = true; + } + + $title = '-'; + if (isset($context['request'])) { + $request = $context['request']; + $title = sprintf('%s %s', $request['method'], $uri = $request['uri'], $uri); + $dedupIdentifier = $request['identifier']; + } elseif (isset($context['cli'])) { + $title = '$ '.$context['cli']['command_line']; + $dedupIdentifier = $context['cli']['identifier']; + } else { + $dedupIdentifier = uniqid('', true); + } + + $contextText = array(); + if (isset($context['source'])) { + $source = $context['source']; + $sourceDescription = sprintf('%s on line %d', $source['name'], $source['line']); + if (isset($source['file_link'])) { + $sourceDescription = sprintf('%s', $source['file_link'], $sourceDescription); + } + + $contextText[] = $sourceDescription; + } + + $contextText = implode('
', $contextText); + $isoDate = $this->extractDate($context, 'c'); + + $output->writeln(<< +
+

$title

+ +
+
+

+ $contextText +

+ {$this->dumper->dump($data, true)} +
+ +HTML + ); + } + + private function extractDate(array $context, string $format = 'r'): string + { + return date($format, $context['timestamp']); + } +} diff --git a/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php new file mode 100644 index 000000000000..16194505234e --- /dev/null +++ b/src/Symfony/Component/VarDumper/Command/ServerDumpCommand.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Command\Descriptor\CliDescriptor; +use Symfony\Component\VarDumper\Command\Descriptor\DumpDescriptorInterface; +use Symfony\Component\VarDumper\Command\Descriptor\HtmlDescriptor; +use Symfony\Component\VarDumper\Dumper\CliDumper; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; +use Symfony\Component\VarDumper\Server\DumpServer; + +/** + * Starts a dump server to collect and output dumps on a single place with multiple formats support. + * + * @author Maxime Steinhausser + * + * @final + */ +class ServerDumpCommand extends Command +{ + protected static $defaultName = 'server:dump'; + + private $server; + + /** @var DumpDescriptorInterface[] */ + private $descriptors; + + public function __construct(DumpServer $server, array $descriptors = array()) + { + $this->server = $server; + $this->descriptors = $descriptors + array( + 'cli' => new CliDescriptor(new CliDumper()), + 'html' => new HtmlDescriptor(new HtmlDumper()), + ); + + parent::__construct(); + } + + protected function configure() + { + $availableFormats = implode(', ', array_keys($this->descriptors)); + + $this + ->addOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format (%s)', $availableFormats), 'cli') + ->setDescription('Starts a dump server that collects and displays dumps in a single place') + ->setHelp(<<<'EOF' +%command.name% starts a dump server that collects and displays +dumps in a single place for debugging you application: + + php %command.full_name% + +You can consult dumped data in HTML format in your browser by providing the --format=html option +and redirecting the output to a file: + + php %command.full_name% --format="html" > dump.html + +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + $format = $input->getOption('format'); + + if (!$descriptor = $this->descriptors[$format] ?? null) { + throw new InvalidArgumentException(sprintf('Unsupported format "%s".', $format)); + } + + $errorIo = $io->getErrorStyle(); + $errorIo->title('Symfony Var Dumper Server'); + + $this->server->start(); + + $errorIo->success(sprintf('Server listening on %s', $this->server->getHost())); + $errorIo->comment('Quit the server with CONTROL-C.'); + + $this->server->listen(function (Data $data, array $context, int $clientId) use ($descriptor, $io) { + $descriptor->describe($io, $data, $context, $clientId); + }); + } +} diff --git a/src/Symfony/Component/VarDumper/Dumper/ContextProvider/CliContextProvider.php b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/CliContextProvider.php new file mode 100644 index 000000000000..be73f795bf2a --- /dev/null +++ b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/CliContextProvider.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Dumper\ContextProvider; + +/** + * Tries to provide context on CLI. + * + * @author Maxime Steinhausser + */ +final class CliContextProvider implements ContextProviderInterface +{ + public function getContext(): ?array + { + if ('cli' !== PHP_SAPI) { + return null; + } + + return array( + 'command_line' => $commandLine = implode(' ', $_SERVER['argv']), + 'identifier' => hash('crc32b', $commandLine.$_SERVER['REQUEST_TIME_FLOAT']), + ); + } +} diff --git a/src/Symfony/Component/VarDumper/Dumper/ContextProvider/ContextProviderInterface.php b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/ContextProviderInterface.php new file mode 100644 index 000000000000..38ef3b0f1853 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/ContextProviderInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Dumper\ContextProvider; + +/** + * Interface to provide contextual data about dump data clones sent to a server. + * + * @author Maxime Steinhausser + */ +interface ContextProviderInterface +{ + /** + * @return array|null Context data or null if unable to provide any context + */ + public function getContext(): ?array; +} diff --git a/src/Symfony/Component/VarDumper/Dumper/ContextProvider/RequestContextProvider.php b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/RequestContextProvider.php new file mode 100644 index 000000000000..73ebf148c5ed --- /dev/null +++ b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/RequestContextProvider.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Dumper\ContextProvider; + +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Tries to provide context from a request. + * + * @author Maxime Steinhausser + */ +final class RequestContextProvider implements ContextProviderInterface +{ + private $requestStack; + + public function __construct(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } + + public function getContext(): ?array + { + if (null === $request = $this->requestStack->getCurrentRequest()) { + return null; + } + + return array( + 'uri' => $request->getUri(), + 'method' => $request->getMethod(), + 'controller' => $request->attributes->get('_controller'), + 'identifier' => spl_object_hash($request), + ); + } +} diff --git a/src/Symfony/Component/VarDumper/Dumper/ContextProvider/SourceContextProvider.php b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/SourceContextProvider.php new file mode 100644 index 000000000000..0c96db52582f --- /dev/null +++ b/src/Symfony/Component/VarDumper/Dumper/ContextProvider/SourceContextProvider.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Dumper\ContextProvider; + +use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; +use Symfony\Component\VarDumper\VarDumper; +use Twig\Template; + +/** + * Tries to provide context from sources (class name, file, line, code excerpt, ...). + * + * @author Nicolas Grekas + * @author Maxime Steinhausser + */ +final class SourceContextProvider implements ContextProviderInterface +{ + private $limit; + private $charset; + private $projectDir; + private $fileLinkFormatter; + + public function __construct(string $charset = null, string $projectDir = null, FileLinkFormatter $fileLinkFormatter = null, int $limit = 9) + { + $this->charset = $charset; + $this->projectDir = $projectDir; + $this->fileLinkFormatter = $fileLinkFormatter; + $this->limit = $limit; + } + + public function getContext(): ?array + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, $this->limit); + + $file = $trace[1]['file']; + $line = $trace[1]['line']; + $name = false; + $fileExcerpt = false; + + for ($i = 2; $i < $this->limit; ++$i) { + if (isset($trace[$i]['class'], $trace[$i]['function']) + && 'dump' === $trace[$i]['function'] + && VarDumper::class === $trace[$i]['class'] + ) { + $file = $trace[$i]['file']; + $line = $trace[$i]['line']; + + while (++$i < $this->limit) { + if (isset($trace[$i]['function'], $trace[$i]['file']) && empty($trace[$i]['class']) && 0 !== strpos($trace[$i]['function'], 'call_user_func')) { + $file = $trace[$i]['file']; + $line = $trace[$i]['line']; + + break; + } elseif (isset($trace[$i]['object']) && $trace[$i]['object'] instanceof Template) { + $template = $trace[$i]['object']; + $name = $template->getTemplateName(); + $src = method_exists($template, 'getSourceContext') ? $template->getSourceContext()->getCode() : (method_exists($template, 'getSource') ? $template->getSource() : false); + $info = $template->getDebugInfo(); + if (isset($info[$trace[$i - 1]['line']])) { + $line = $info[$trace[$i - 1]['line']]; + $file = method_exists($template, 'getSourceContext') ? $template->getSourceContext()->getPath() : null; + + if ($src) { + $src = explode("\n", $src); + $fileExcerpt = array(); + + for ($i = max($line - 3, 1), $max = min($line + 3, count($src)); $i <= $max; ++$i) { + $fileExcerpt[] = ''.$this->htmlEncode($src[$i - 1]).''; + } + + $fileExcerpt = '
    '.implode("\n", $fileExcerpt).'
'; + } + } + break; + } + } + break; + } + } + + if (false === $name) { + $name = str_replace('\\', '/', $file); + $name = substr($name, strrpos($name, '/') + 1); + } + + $context = array('name' => $name, 'file' => $file, 'line' => $line); + $context['file_excerpt'] = $fileExcerpt; + + if (null !== $this->projectDir) { + $context['project_dir'] = $this->projectDir; + if (0 === strpos($file, $this->projectDir)) { + $context['file_relative'] = ltrim(substr($file, strlen($this->projectDir)), DIRECTORY_SEPARATOR); + } + } + + if ($this->fileLinkFormatter && $fileLink = $this->fileLinkFormatter->format($context['file'], $context['line'])) { + $context['file_link'] = $fileLink; + } + + return $context; + } + + private function htmlEncode(string $s): string + { + $html = ''; + + $dumper = new HtmlDumper(function ($line) use (&$html) { $html .= $line; }, $this->charset); + $dumper->setDumpHeader(''); + $dumper->setDumpBoundaries('', ''); + + $cloner = new VarCloner(); + $dumper->dump($cloner->cloneVar($s)); + + return substr(strip_tags($html), 1, -1); + } +} diff --git a/src/Symfony/Component/VarDumper/Dumper/ServerDumper.php b/src/Symfony/Component/VarDumper/Dumper/ServerDumper.php new file mode 100644 index 000000000000..106ae89aed55 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Dumper/ServerDumper.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Dumper; + +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Dumper\ContextProvider\ContextProviderInterface; + +/** + * ServerDumper forwards serialized Data clones to a server. + * + * @author Maxime Steinhausser + */ +class ServerDumper implements DataDumperInterface +{ + private $host; + private $wrappedDumper; + private $contextProviders; + private $socket; + + /** + * @param string $host The server host + * @param DataDumperInterface|null $wrappedDumper A wrapped instance used whenever we failed contacting the server + * @param ContextProviderInterface[] $contextProviders Context providers indexed by context name + */ + public function __construct(string $host, DataDumperInterface $wrappedDumper = null, array $contextProviders = array()) + { + if (false === strpos($host, '://')) { + $host = 'tcp://'.$host; + } + + $this->host = $host; + $this->wrappedDumper = $wrappedDumper; + $this->contextProviders = $contextProviders; + } + + public function getContextProviders(): array + { + return $this->contextProviders; + } + + /** + * {@inheritdoc} + */ + public function dump(Data $data, $output = null): void + { + set_error_handler(array(self::class, 'nullErrorHandler')); + + $failed = false; + try { + if (!$this->socket = $this->socket ?: $this->createSocket()) { + $failed = true; + + return; + } + } finally { + restore_error_handler(); + if ($failed && $this->wrappedDumper) { + $this->wrappedDumper->dump($data); + } + } + + set_error_handler(array(self::class, 'nullErrorHandler')); + + $context = array('timestamp' => time()); + foreach ($this->contextProviders as $name => $provider) { + $context[$name] = $provider->getContext(); + } + $context = array_filter($context); + + $encodedPayload = base64_encode(serialize(array($data, $context)))."\n"; + $failed = false; + + try { + $retry = 3; + while ($retry > 0 && $failed = (-1 === stream_socket_sendto($this->socket, $encodedPayload))) { + stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR); + if ($failed = !$this->socket = $this->createSocket()) { + break; + } + + --$retry; + } + } finally { + restore_error_handler(); + if ($failed && $this->wrappedDumper) { + $this->wrappedDumper->dump($data); + } + } + } + + public function isServerListening(): bool + { + set_error_handler(array(self::class, 'nullErrorHandler')); + + try { + return $this->socket || $this->socket = $this->createSocket(); + } finally { + restore_error_handler(); + } + } + + private static function nullErrorHandler() + { + // noop + } + + private function createSocket() + { + $socket = stream_socket_client($this->host, $errno, $errstr, 1, STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT); + + if ($socket) { + stream_set_blocking($socket, false); + } + + return $socket; + } +} diff --git a/src/Symfony/Component/VarDumper/Resources/css/htmlDescriptor.css b/src/Symfony/Component/VarDumper/Resources/css/htmlDescriptor.css new file mode 100644 index 000000000000..4855dbc0e444 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Resources/css/htmlDescriptor.css @@ -0,0 +1,79 @@ +body { + display: flex; + flex-direction: column-reverse; + justify-content: flex-end; + word-wrap: break-word; + background-color: #F9F9F9; + color: #222; + font-family: Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.4; +} +p { + margin: 0; +} +a { + color: #218BC3; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +code { + color: #cc2255; + background-color: #f7f7f9; + border: 1px solid #e1e1e8; + border-radius: 3px; + margin-right: 5px; + padding: 0 3px; +} +.text-small { + font-size: 12px !important; +} +article { + margin: 5px; + margin-bottom: 10px; +} +article > header { + display: flex; + flex-direction: row; + align-items: baseline; +} +article > header > * { + flex: 1; + display: flex; + align-items: baseline; +} +article > header > h2 { + font-size: 14px; + color: #222; + font-weight: normal; + font-family: "Lucida Console", monospace, sans-serif; + word-break: break-all; + margin-right: 5px; + user-select: all; +} +article > header > h2 > code { + white-space: nowrap; + user-select: none; +} +article > header > time { + flex: 0; + text-align: right; + white-space: nowrap; + color: #999; + font-style: italic; +} +article > section.body { + border: 1px solid #d8d8d8; + background: #FFF; + padding: 10px; + border-radius: 3px; +} +pre.sf-dump { + border-radius: 3px; + margin-bottom: 0; +} +.hidden { + display: none; !important +} diff --git a/src/Symfony/Component/VarDumper/Resources/js/htmlDescriptor.js b/src/Symfony/Component/VarDumper/Resources/js/htmlDescriptor.js new file mode 100644 index 000000000000..63101e57c3c7 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Resources/js/htmlDescriptor.js @@ -0,0 +1,10 @@ +document.addEventListener('DOMContentLoaded', function() { + let prev = null; + Array.from(document.getElementsByTagName('article')).reverse().forEach(function (article) { + const dedupId = article.dataset.dedupId; + if (dedupId === prev) { + article.getElementsByTagName('header')[0].classList.add('hidden'); + } + prev = dedupId; + }); +}); diff --git a/src/Symfony/Component/VarDumper/Server/DumpServer.php b/src/Symfony/Component/VarDumper/Server/DumpServer.php new file mode 100644 index 000000000000..51b5228b1ec6 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Server/DumpServer.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Server; + +use Psr\Log\LoggerInterface; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * A server collecting Data clones sent by a ServerDumper. + * + * @author Maxime Steinhausser + * + * @final + */ +class DumpServer +{ + private $host; + private $socket; + private $logger; + + public function __construct(string $host, LoggerInterface $logger = null) + { + if (false === strpos($host, '://')) { + $host = 'tcp://'.$host; + } + + $this->host = $host; + $this->logger = $logger; + } + + public function start(): void + { + if (!$this->socket = stream_socket_server($this->host, $errno, $errstr)) { + throw new \RuntimeException(sprintf('Server start failed on "%s": %s %s.', $this->host, $errstr, $errno)); + } + } + + public function listen(callable $callback): void + { + if (null === $this->socket) { + $this->start(); + } + + foreach ($this->getMessages() as $clientId => $message) { + $payload = @unserialize(base64_decode($message)); + + // Impossible to decode the message, give up. + if (false === $payload) { + if ($this->logger) { + $this->logger->warning('Unable to decode a message from {clientId} client.', array('clientId' => $clientId)); + } + + continue; + } + + if (!is_array($payload) || count($payload) < 2 || !$payload[0] instanceof Data || !is_array($payload[1])) { + if ($this->logger) { + $this->logger->warning('Invalid payload from {clientId} client. Expected an array of two elements (Data $data, array $context)', array('clientId' => $clientId)); + } + + continue; + } + + list($data, $context) = $payload; + + $callback($data, $context, $clientId); + } + } + + public function getHost(): string + { + return $this->host; + } + + private function getMessages(): iterable + { + $sockets = array((int) $this->socket => $this->socket); + $write = array(); + + while (true) { + $read = $sockets; + stream_select($read, $write, $write, null); + + foreach ($read as $stream) { + if ($this->socket === $stream) { + $stream = stream_socket_accept($this->socket); + $sockets[(int) $stream] = $stream; + } elseif (feof($stream)) { + unset($sockets[(int) $stream]); + fclose($stream); + } else { + yield (int) $stream => fgets($stream); + } + } + } + } +} diff --git a/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php b/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php new file mode 100644 index 000000000000..0ac5cad84515 --- /dev/null +++ b/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarDumper\Tests\Dumper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\PhpProcess; +use Symfony\Component\Process\Process; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\ContextProvider\ContextProviderInterface; +use Symfony\Component\VarDumper\Dumper\DataDumperInterface; +use Symfony\Component\VarDumper\Dumper\ServerDumper; + +class ServerDumperTest extends TestCase +{ + private const VAR_DUMPER_SERVER = 'tcp://127.0.0.1:9913'; + + public function testDumpForwardsToWrappedDumperWhenServerIsUnavailable() + { + $wrappedDumper = $this->getMockBuilder(DataDumperInterface::class)->getMock(); + + $dumper = new ServerDumper(self::VAR_DUMPER_SERVER, $wrappedDumper); + + $cloner = new VarCloner(); + $data = $cloner->cloneVar('foo'); + + $wrappedDumper->expects($this->once())->method('dump')->with($data); + + $dumper->dump($data); + } + + public function testIsServerListening() + { + $dumper = new ServerDumper(self::VAR_DUMPER_SERVER); + + $this->assertFalse($dumper->isServerListening()); + + $process = $this->getServerProcess(); + $process->start(function ($type) use ($process) { + if (Process::ERR === $type) { + $process->stop(); + $this->fail(); + } + }); + + sleep(3); + + $this->assertTrue($dumper->isServerListening()); + + $process->stop(); + } + + public function testDump() + { + $wrappedDumper = $this->getMockBuilder(DataDumperInterface::class)->getMock(); + $wrappedDumper->expects($this->never())->method('dump'); // test wrapped dumper is not used + + $cloner = new VarCloner(); + $data = $cloner->cloneVar('foo'); + $dumper = new ServerDumper(self::VAR_DUMPER_SERVER, $wrappedDumper, array( + 'foo_provider' => new class() implements ContextProviderInterface { + public function getContext(): ?array + { + return array('foo'); + } + }, + )); + + $dumped = null; + $process = $this->getServerProcess(); + $process->start(function ($type, $buffer) use ($process, &$dumped) { + if (Process::ERR === $type) { + $process->stop(); + $this->fail(); + } else { + $dumped .= $buffer; + } + }); + + sleep(3); + + $dumper->dump($data); + + $process->wait(); + + $this->assertTrue($process->isSuccessful()); + $this->assertStringMatchesFormat(<<<'DUMP' +(3) "foo" +[ + "timestamp" => %d + "foo_provider" => [ + (3) "foo" + ] +] +%d +DUMP + , $dumped); + } + + private function getServerProcess(): Process + { + $process = new PhpProcess(file_get_contents(__DIR__.'/../Fixtures/dump_server.php'), null, array( + 'COMPONENT_ROOT' => __DIR__.'/../../', + 'VAR_DUMPER_SERVER' => self::VAR_DUMPER_SERVER, + )); + $process->inheritEnvironmentVariables(true); + + return $process->setTimeout(9); + } +} diff --git a/src/Symfony/Component/VarDumper/Tests/Fixtures/dump_server.php b/src/Symfony/Component/VarDumper/Tests/Fixtures/dump_server.php new file mode 100644 index 000000000000..5c79ea5151dc --- /dev/null +++ b/src/Symfony/Component/VarDumper/Tests/Fixtures/dump_server.php @@ -0,0 +1,36 @@ +setMaxItems(-1); + +$dumper = new CliDumper(null, null, CliDumper::DUMP_LIGHT_ARRAY | CliDumper::DUMP_STRING_LENGTH); +$dumper->setColors(false); + +VarDumper::setHandler(function ($var) use ($cloner, $dumper) { + $data = $cloner->cloneVar($var)->withRefHandles(false); + $dumper->dump($data); +}); + +$server = new DumpServer(getenv('VAR_DUMPER_SERVER')); + +$server->start(); + +$server->listen(function (Data $data, array $context, $clientId) { + dump((string) $data, $context, $clientId); + + exit(0); +}); diff --git a/src/Symfony/Component/VarDumper/composer.json b/src/Symfony/Component/VarDumper/composer.json index 3337070dd33f..6abab0af7a0f 100644 --- a/src/Symfony/Component/VarDumper/composer.json +++ b/src/Symfony/Component/VarDumper/composer.json @@ -22,14 +22,17 @@ }, "require-dev": { "ext-iconv": "*", + "symfony/process": "~3.4|~4.0", "twig/twig": "~1.34|~2.4" }, "conflict": { - "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0" + "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0", + "symfony/console": "<3.4" }, "suggest": { "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", - "ext-intl": "To show region name in time zone dump" + "ext-intl": "To show region name in time zone dump", + "symfony/console": "To use the ServerDumpCommand" }, "autoload": { "files": [ "Resources/functions/dump.php" ],