From b851de2dfa3aa9310bcae3526d4b15ef34373b86 Mon Sep 17 00:00:00 2001 From: ILDaviz Date: Sat, 25 Jul 2026 20:24:35 +0200 Subject: [PATCH 1/2] fix: capture VarDumper output and enable auto-dump for AST expressions --- src/Psy/Presenter.php | 6 ++- src/Tinker.php | 92 +++++++++++++++++++++++++++++--- tests/TinkerTest.php | 118 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 8 deletions(-) diff --git a/src/Psy/Presenter.php b/src/Psy/Presenter.php index 49a0611..35fd3e4 100644 --- a/src/Psy/Presenter.php +++ b/src/Psy/Presenter.php @@ -46,7 +46,11 @@ public function present($value, ?int $depth = null, int $options = 0): string }); if (isset(Tinker::$statements[Tinker::$current])) { - Tinker::$statements[Tinker::$current]['html'] = $output; + if (isset(Tinker::$statements[Tinker::$current]['html']) && Tinker::$statements[Tinker::$current]['html'] !== '') { + Tinker::$statements[Tinker::$current]['html'] .= \PHP_EOL.$output; + } else { + Tinker::$statements[Tinker::$current]['html'] = $output; + } } return parent::present($value, $depth, $options); diff --git a/src/Tinker.php b/src/Tinker.php index 11e5bc0..bf5ca38 100644 --- a/src/Tinker.php +++ b/src/Tinker.php @@ -2,23 +2,30 @@ namespace TweakPHP\Client; +use PhpParser\Node\Stmt\Expression; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard; +use Psy\CodeCleaner\NoReturnValue; use Psy\Configuration; use Psy\Exception\BreakException; use Psy\Shell; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\VarDumper\VarDumper; use TweakPHP\Client\Database\QueryCollector; use TweakPHP\Client\Output\StreamingOutput; use TweakPHP\Client\OutputModifiers\OutputModifier; class Tinker { + public static bool $dumpOccurred = false; + protected OutputInterface $output; protected Shell $shell; + protected Configuration $config; + protected OutputModifier $outputModifier; public static array $statements = []; @@ -29,15 +36,21 @@ public function __construct(OutputModifier $outputModifier, Configuration $confi { $this->output = new BufferedOutput; + $this->config = $config; + $this->shell = $this->createShell($this->output, $config); $this->outputModifier = $outputModifier; + + $this->registerVarDumperHandler($config); } public function execute(string $rawPHPCode): array { self::$statements = []; + $this->registerVarDumperHandler($this->config); + $rawPHPCode = $this->normalizePHPCode($rawPHPCode); $parserFactory = new ParserFactory; @@ -47,6 +60,10 @@ public function execute(string $rawPHPCode): array $prettyPrinter = new Standard; foreach ($parser->parse($rawPHPCode) as $key => $stmt) { $code = $prettyPrinter->prettyPrint([$stmt]); + $executableCode = $stmt instanceof Expression + ? $prettyPrinter->prettyPrintExpr($stmt->expr) + : $code; + self::$current = $key; self::$statements[] = [ 'line' => $stmt->getStartLine(), @@ -55,13 +72,21 @@ public function execute(string $rawPHPCode): array QueryCollector::start(); try { - $output = $this->doExecute($code); + $output = $this->doExecute($executableCode); } finally { $queries = QueryCollector::stop(); self::$statements[$key]['queries'] = $queries; } - self::$statements[$key]['output'] = $output; + if ($output !== '') { + if (isset(self::$statements[$key]['output']) && self::$statements[$key]['output'] !== '' && self::$statements[$key]['output'] !== $output) { + self::$statements[$key]['output'] .= "\n".$output; + } else { + self::$statements[$key]['output'] = $output; + } + } elseif (! isset(self::$statements[$key]['output'])) { + self::$statements[$key]['output'] = ''; + } $queryErrors = QueryCollector::errors(); if ($queryErrors !== []) { @@ -99,6 +124,8 @@ public function executeStreaming(string $rawPHPCode, callable $onEvent): void { self::$statements = []; + $this->registerVarDumperHandler($this->config); + $rawPHPCode = $this->normalizePHPCode($rawPHPCode); $parserFactory = new ParserFactory; @@ -109,6 +136,10 @@ public function executeStreaming(string $rawPHPCode, callable $onEvent): void foreach ($parser->parse($rawPHPCode) as $key => $stmt) { $code = $prettyPrinter->prettyPrint([$stmt]); + $executableCode = $stmt instanceof Expression + ? $prettyPrinter->prettyPrintExpr($stmt->expr) + : $code; + self::$current = $key; self::$statements[] = [ 'line' => $stmt->getStartLine(), @@ -122,7 +153,7 @@ public function executeStreaming(string $rawPHPCode, callable $onEvent): void 'code' => $code, ]); - if (! $this->executeStreamingStatement($code, $key, $onEvent)) { + if (! $this->executeStreamingStatement($executableCode, $key, $onEvent)) { return; } } @@ -197,12 +228,22 @@ protected function executeStreamingStatement(string $code, int $key, callable $o protected function doExecute(string $code): string { + self::$dumpOccurred = false; $this->output = new BufferedOutput; $this->shell->setOutput($this->output); - $this->shell->execute($code, true); - $result = $this->outputModifier->modify($this->cleanOutput($this->output->fetch())); + $return = $this->shell->execute($code, true); + $output = $this->outputModifier->modify($this->cleanOutput($this->output->fetch())); + + if (! self::$dumpOccurred && $return !== null && ! ($return instanceof NoReturnValue)) { + $presented = $this->config->getPresenter()->present($return); + if ($output !== '') { + $output .= "\n".$presented; + } else { + $output = $presented; + } + } - return trim($result); + return trim($output); } /** @@ -210,6 +251,7 @@ protected function doExecute(string $code): string */ protected function doExecuteStreaming(string $code, int $index, callable $onEvent): void { + self::$dumpOccurred = false; $this->output = new StreamingOutput(function (string $chunk) use ($index, $onEvent): void { if ($chunk === '') { return; @@ -222,7 +264,18 @@ protected function doExecuteStreaming(string $code, int $index, callable $onEven ]); }); $this->shell->setOutput($this->output); - $this->shell->execute($code, true); + $return = $this->shell->execute($code, true); + + if (! self::$dumpOccurred && $return !== null && ! ($return instanceof NoReturnValue)) { + $output = $this->config->getPresenter()->present($return); + if ($output !== '') { + $onEvent([ + 'type' => 'output', + 'index' => $index, + 'data' => $output, + ]); + } + } } protected function createShell(OutputInterface $output, Configuration $config): Shell @@ -252,6 +305,31 @@ protected function normalizePHPCode(string $rawPHPCode): string return $rawPHPCode; } + protected function registerVarDumperHandler(Configuration $config): void + { + if (! class_exists(VarDumper::class)) { + return; + } + + VarDumper::setHandler(function ($var) use ($config) { + self::$dumpOccurred = true; + $output = $config->getPresenter()->present($var); + if (isset(self::$statements[self::$current])) { + if (isset(self::$statements[self::$current]['output']) && self::$statements[self::$current]['output'] !== '') { + self::$statements[self::$current]['output'] .= "\n".$output; + } else { + self::$statements[self::$current]['output'] = $output; + } + } + + if ($this->output instanceof StreamingOutput) { + $this->output->write($output, true); + } + + return $output; + }); + } + public function getShell(): Shell { return $this->shell; diff --git a/tests/TinkerTest.php b/tests/TinkerTest.php index ac63bb5..a999530 100644 --- a/tests/TinkerTest.php +++ b/tests/TinkerTest.php @@ -297,6 +297,124 @@ public function test_execute_streaming_propagates_parse_errors(): void $this->createTinker()->executeStreaming('echo ;', static function (array $event): void {}); } + public function test_execute_captures_symfony_var_dumper_output(): void + { + $tinker = $this->createTinker(); + + ob_start(); + $result = $tinker->execute('dump("hello");'); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + $this->assertNotEmpty($result['output']); + $this->assertSame('"hello"', $result['output'][0]['output']); + $this->assertStringContainsString('hello', $result['output'][0]['html']); + } + + public function test_execute_evaluates_expressions_ending_with_semicolon(): void + { + $tinker = $this->createTinker(); + + $result = $tinker->execute('"hello";'); + + $this->assertNotEmpty($result['output']); + $this->assertSame('"hello"', $result['output'][0]['output']); + $this->assertStringContainsString('hello', $result['output'][0]['html']); + } + + public function test_execute_handles_multiple_dumps_in_single_statement(): void + { + $tinker = $this->createTinker(); + + ob_start(); + $result = $tinker->execute('foreach (["one", "two"] as $item) { dump($item); }'); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + $this->assertNotEmpty($result['output']); + $this->assertStringContainsString('"one"', $result['output'][0]['output']); + $this->assertStringContainsString('"two"', $result['output'][0]['output']); + $this->assertStringContainsString('one', $result['output'][0]['html']); + $this->assertStringContainsString('two', $result['output'][0]['html']); + } + + public function test_execute_handles_mixed_statements_with_dump_and_expressions(): void + { + $tinker = $this->createTinker(); + + $result = $tinker->execute('$x = 10; dump($x); $x + 5;'); + + $this->assertCount(3, $result['output']); + $this->assertSame('10', $result['output'][0]['output']); + $this->assertSame('10', $result['output'][1]['output']); + $this->assertSame('15', $result['output'][2]['output']); + } + + public function test_execute_streaming_emits_dump_output(): void + { + $tinker = $this->createTinker(); + $events = []; + + ob_start(); + $tinker->executeStreaming('dump("stream_test");', function (array $event) use (&$events): void { + $events[] = $event; + }); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + + $outputEvents = array_filter($events, fn (array $event): bool => $event['type'] === 'output'); + $output = implode('', array_column($outputEvents, 'data')); + + $this->assertStringContainsString('stream_test', $output); + } + + public function test_execute_streaming_handles_multiple_dumps_and_mixed_outputs(): void + { + $tinker = $this->createTinker(); + $events = []; + + ob_start(); + $tinker->executeStreaming('foreach (["foo", "bar"] as $item) { dump($item); }', function (array $event) use (&$events): void { + $events[] = $event; + }); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + + $outputEvents = array_filter($events, fn (array $event): bool => $event['type'] === 'output'); + $output = implode('', array_column($outputEvents, 'data')); + + $this->assertStringContainsString('foo', $output); + $this->assertStringContainsString('bar', $output); + } + + public function test_execute_streaming_emits_expression_output(): void + { + $tinker = $this->createTinker(); + $events = []; + + $tinker->executeStreaming('"stream_expr";', function (array $event) use (&$events): void { + $events[] = $event; + }); + + $outputEvents = array_filter($events, fn (array $event): bool => $event['type'] === 'output'); + $output = implode('', array_column($outputEvents, 'data')); + + $this->assertStringContainsString('stream_expr', $output); + } + + public function test_execute_handles_non_expression_statements(): void + { + $tinker = $this->createTinker(); + + $result = $tinker->execute('function myTestFunc() { return 42; } myTestFunc();'); + + $this->assertCount(2, $result['output']); + $this->assertSame('', $result['output'][0]['output']); + $this->assertSame('42', $result['output'][1]['output']); + } + private function createTinker(): Tinker { $config = new Configuration([ From ae397fcc55f0533150d56da5c7675c9aae476827 Mon Sep 17 00:00:00 2001 From: ILDaviz Date: Sat, 25 Jul 2026 21:38:33 +0200 Subject: [PATCH 2/2] fix: restore VarDumper handler after execution and preserve output order --- src/Tinker.php | 191 +++++++++++++++++++++---------------------- tests/TinkerTest.php | 52 ++++++++++++ 2 files changed, 146 insertions(+), 97 deletions(-) diff --git a/src/Tinker.php b/src/Tinker.php index bf5ca38..d916e9b 100644 --- a/src/Tinker.php +++ b/src/Tinker.php @@ -41,8 +41,6 @@ public function __construct(OutputModifier $outputModifier, Configuration $confi $this->shell = $this->createShell($this->output, $config); $this->outputModifier = $outputModifier; - - $this->registerVarDumperHandler($config); } public function execute(string $rawPHPCode): array @@ -51,70 +49,66 @@ public function execute(string $rawPHPCode): array $this->registerVarDumperHandler($this->config); - $rawPHPCode = $this->normalizePHPCode($rawPHPCode); - - $parserFactory = new ParserFactory; - $parser = method_exists($parserFactory, 'createForHostVersion') - ? $parserFactory->createForHostVersion() - : $parserFactory->create(ParserFactory::PREFER_PHP7); - $prettyPrinter = new Standard; - foreach ($parser->parse($rawPHPCode) as $key => $stmt) { - $code = $prettyPrinter->prettyPrint([$stmt]); - $executableCode = $stmt instanceof Expression - ? $prettyPrinter->prettyPrintExpr($stmt->expr) - : $code; - - self::$current = $key; - self::$statements[] = [ - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]; + try { + $rawPHPCode = $this->normalizePHPCode($rawPHPCode); + + $parserFactory = new ParserFactory; + $parser = method_exists($parserFactory, 'createForHostVersion') + ? $parserFactory->createForHostVersion() + : $parserFactory->create(ParserFactory::PREFER_PHP7); + $prettyPrinter = new Standard; + foreach ($parser->parse($rawPHPCode) as $key => $stmt) { + $code = $prettyPrinter->prettyPrint([$stmt]); + $executableCode = $stmt instanceof Expression + ? $prettyPrinter->prettyPrintExpr($stmt->expr) + : $code; + + self::$current = $key; + self::$statements[] = [ + 'line' => $stmt->getStartLine(), + 'code' => $code, + ]; + + QueryCollector::start(); + try { + $output = $this->doExecute($executableCode); + } finally { + $queries = QueryCollector::stop(); + self::$statements[$key]['queries'] = $queries; + } - QueryCollector::start(); - try { - $output = $this->doExecute($executableCode); - } finally { - $queries = QueryCollector::stop(); - self::$statements[$key]['queries'] = $queries; - } + self::$statements[$key]['output'] = $output; - if ($output !== '') { - if (isset(self::$statements[$key]['output']) && self::$statements[$key]['output'] !== '' && self::$statements[$key]['output'] !== $output) { - self::$statements[$key]['output'] .= "\n".$output; - } else { - self::$statements[$key]['output'] = $output; + $queryErrors = QueryCollector::errors(); + if ($queryErrors !== []) { + self::$statements[$key]['query_errors'] = $queryErrors; } - } elseif (! isset(self::$statements[$key]['output'])) { - self::$statements[$key]['output'] = ''; } - $queryErrors = QueryCollector::errors(); - if ($queryErrors !== []) { - self::$statements[$key]['query_errors'] = $queryErrors; + $allQueries = []; + $allQueryErrors = []; + foreach (self::$statements as $stmt) { + if (isset($stmt['queries'])) { + $allQueries = array_merge($allQueries, $stmt['queries']); + } + if (isset($stmt['query_errors'])) { + $allQueryErrors = array_merge($allQueryErrors, $stmt['query_errors']); + } } - } - $allQueries = []; - $allQueryErrors = []; - foreach (self::$statements as $stmt) { - if (isset($stmt['queries'])) { - $allQueries = array_merge($allQueries, $stmt['queries']); - } - if (isset($stmt['query_errors'])) { - $allQueryErrors = array_merge($allQueryErrors, $stmt['query_errors']); - } - } + $result = [ + 'output' => self::$statements, + 'queries' => $allQueries, + ]; - $result = [ - 'output' => self::$statements, - 'queries' => $allQueries, - ]; + if ($allQueryErrors !== []) { + $result['query_errors'] = $allQueryErrors; + } - if ($allQueryErrors !== []) { - $result['query_errors'] = $allQueryErrors; + return $result; + } finally { + $this->restoreVarDumperHandler(); } - - return $result; } /** @@ -126,39 +120,43 @@ public function executeStreaming(string $rawPHPCode, callable $onEvent): void $this->registerVarDumperHandler($this->config); - $rawPHPCode = $this->normalizePHPCode($rawPHPCode); - - $parserFactory = new ParserFactory; - $parser = method_exists($parserFactory, 'createForHostVersion') - ? $parserFactory->createForHostVersion() - : $parserFactory->create(ParserFactory::PREFER_PHP7); - $prettyPrinter = new Standard; - - foreach ($parser->parse($rawPHPCode) as $key => $stmt) { - $code = $prettyPrinter->prettyPrint([$stmt]); - $executableCode = $stmt instanceof Expression - ? $prettyPrinter->prettyPrintExpr($stmt->expr) - : $code; - - self::$current = $key; - self::$statements[] = [ - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]; + try { + $rawPHPCode = $this->normalizePHPCode($rawPHPCode); + + $parserFactory = new ParserFactory; + $parser = method_exists($parserFactory, 'createForHostVersion') + ? $parserFactory->createForHostVersion() + : $parserFactory->create(ParserFactory::PREFER_PHP7); + $prettyPrinter = new Standard; + + foreach ($parser->parse($rawPHPCode) as $key => $stmt) { + $code = $prettyPrinter->prettyPrint([$stmt]); + $executableCode = $stmt instanceof Expression + ? $prettyPrinter->prettyPrintExpr($stmt->expr) + : $code; + + self::$current = $key; + self::$statements[] = [ + 'line' => $stmt->getStartLine(), + 'code' => $code, + ]; - $onEvent([ - 'type' => 'statement.started', - 'index' => $key, - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]); + $onEvent([ + 'type' => 'statement.started', + 'index' => $key, + 'line' => $stmt->getStartLine(), + 'code' => $code, + ]); - if (! $this->executeStreamingStatement($executableCode, $key, $onEvent)) { - return; + if (! $this->executeStreamingStatement($executableCode, $key, $onEvent)) { + return; + } } - } - $onEvent(['type' => 'completed']); + $onEvent(['type' => 'completed']); + } finally { + $this->restoreVarDumperHandler(); + } } protected function executeStreamingStatement(string $code, int $key, callable $onEvent): bool @@ -237,7 +235,7 @@ protected function doExecute(string $code): string if (! self::$dumpOccurred && $return !== null && ! ($return instanceof NoReturnValue)) { $presented = $this->config->getPresenter()->present($return); if ($output !== '') { - $output .= "\n".$presented; + $output .= \PHP_EOL.$presented; } else { $output = $presented; } @@ -314,22 +312,21 @@ protected function registerVarDumperHandler(Configuration $config): void VarDumper::setHandler(function ($var) use ($config) { self::$dumpOccurred = true; $output = $config->getPresenter()->present($var); - if (isset(self::$statements[self::$current])) { - if (isset(self::$statements[self::$current]['output']) && self::$statements[self::$current]['output'] !== '') { - self::$statements[self::$current]['output'] .= "\n".$output; - } else { - self::$statements[self::$current]['output'] = $output; - } - } - - if ($this->output instanceof StreamingOutput) { - $this->output->write($output, true); - } + $this->output->write($output, true); return $output; }); } + protected function restoreVarDumperHandler(): void + { + if (! class_exists(VarDumper::class)) { + return; + } + + VarDumper::setHandler(null); + } + public function getShell(): Shell { return $this->shell; diff --git a/tests/TinkerTest.php b/tests/TinkerTest.php index a999530..81f7a3d 100644 --- a/tests/TinkerTest.php +++ b/tests/TinkerTest.php @@ -415,6 +415,58 @@ public function test_execute_handles_non_expression_statements(): void $this->assertSame('42', $result['output'][1]['output']); } + public function test_execute_preserves_echo_dump_order_in_single_statement(): void + { + $tinker = $this->createTinker(); + + ob_start(); + $result = $tinker->execute('if (true) { echo "A\n"; dump("B"); }'); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + $this->assertSame("A\n\"B\"", $result['output'][0]['output']); + } + + public function test_execute_streaming_preserves_echo_dump_order(): void + { + $tinker = $this->createTinker(); + $events = []; + + ob_start(); + $tinker->executeStreaming('if (true) { echo "A\n"; dump("B"); }', function (array $event) use (&$events): void { + $events[] = $event; + }); + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout); + + $outputEvents = array_filter($events, fn (array $event): bool => $event['type'] === 'output'); + $output = implode('', array_column($outputEvents, 'data')); + + $this->assertStringContainsString('A', $output); + $this->assertStringContainsString('"B"', $output); + $this->assertLessThan(strpos($output, '"B"'), strpos($output, 'A')); + } + + public function test_execute_restores_var_dumper_handler_after_execution(): void + { + $tinker = $this->createTinker(); + + $result = $tinker->execute('1;'); + $outputBefore = $result['output'][0]['output']; + + // The default handler must be back in place: VAR_DUMPER_FORMAT=html + // makes it dump to php://output so it can be captured with ob_start. + $_SERVER['VAR_DUMPER_FORMAT'] = 'html'; + ob_start(); + dump('AFTER_EXECUTE'); + $captured = ob_get_clean(); + unset($_SERVER['VAR_DUMPER_FORMAT']); + + $this->assertStringContainsString('AFTER_EXECUTE', $captured); + $this->assertSame($outputBefore, Tinker::$statements[0]['output']); + } + private function createTinker(): Tinker { $config = new Configuration([