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..d916e9b 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,6 +36,8 @@ public function __construct(OutputModifier $outputModifier, Configuration $confi { $this->output = new BufferedOutput; + $this->config = $config; + $this->shell = $this->createShell($this->output, $config); $this->outputModifier = $outputModifier; @@ -38,58 +47,68 @@ public function execute(string $rawPHPCode): array { self::$statements = []; - $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]); - self::$current = $key; - self::$statements[] = [ - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]; + $this->registerVarDumperHandler($this->config); - QueryCollector::start(); - try { - $output = $this->doExecute($code); - } finally { - $queries = QueryCollector::stop(); - self::$statements[$key]['queries'] = $queries; + 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; + } + + self::$statements[$key]['output'] = $output; + + $queryErrors = QueryCollector::errors(); + if ($queryErrors !== []) { + self::$statements[$key]['query_errors'] = $queryErrors; + } } - self::$statements[$key]['output'] = $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; } /** @@ -99,35 +118,45 @@ public function executeStreaming(string $rawPHPCode, callable $onEvent): void { self::$statements = []; - $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]); - self::$current = $key; - self::$statements[] = [ - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]; - - $onEvent([ - 'type' => 'statement.started', - 'index' => $key, - 'line' => $stmt->getStartLine(), - 'code' => $code, - ]); + $this->registerVarDumperHandler($this->config); - if (! $this->executeStreamingStatement($code, $key, $onEvent)) { - return; + 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, + ]); + + 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 @@ -197,12 +226,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 .= \PHP_EOL.$presented; + } else { + $output = $presented; + } + } - return trim($result); + return trim($output); } /** @@ -210,6 +249,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 +262,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 +303,30 @@ 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); + $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 ac63bb5..81f7a3d 100644 --- a/tests/TinkerTest.php +++ b/tests/TinkerTest.php @@ -297,6 +297,176 @@ 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']); + } + + 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([