first commit

This commit is contained in:
2026-01-25 18:18:09 +08:00
commit 509312e604
8136 changed files with 2349298 additions and 0 deletions

21
vendor/psy/psysh/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2012-2025 Justin Hileman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

36
vendor/psy/psysh/README.md vendored Normal file
View File

@ -0,0 +1,36 @@
# PsySH
PsySH is a runtime developer console, interactive debugger and [REPL](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop) for PHP. Learn more at [psysh.org](http://psysh.org/) and [in the manual](https://github.com/bobthecow/psysh/wiki/Home).
[![Package version](https://img.shields.io/packagist/v/psy/psysh.svg?style=flat-square)](https://packagist.org/packages/psy/psysh)
[![Monthly downloads](http://img.shields.io/packagist/dm/psy/psysh.svg?style=flat-square)](https://packagist.org/packages/psy/psysh)
[![Made out of awesome](https://img.shields.io/badge/made_out_of_awesome-✓-brightgreen.svg?style=flat-square)](http://psysh.org)
[![Build status](https://img.shields.io/github/actions/workflow/status/bobthecow/psysh/tests.yml?branch=main&style=flat-square)](https://github.com/bobthecow/psysh/actions?query=branch:main)
[![StyleCI](https://styleci.io/repos/4549925/shield)](https://styleci.io/repos/4549925)
<a id="downloading-the-manual"></a>
## [PsySH manual](https://github.com/bobthecow/psysh/wiki/Home)
### [💾 Installation](https://github.com/bobthecow/psysh/wiki/Installation)
* [📕 PHP manual](https://github.com/bobthecow/psysh/wiki/PHP-manual)
* [🤓 Windows](https://github.com/bobthecow/psysh/wiki/Windows)
### [🖥 Usage](https://github.com/bobthecow/psysh/wiki/Usage)
* [✨ Magic variables](https://github.com/bobthecow/psysh/wiki/Magic-variables)
* [⏳ Managing history](https://github.com/bobthecow/psysh/wiki/History)
* [💲 System shell integration](https://github.com/bobthecow/psysh/wiki/Shell-integration)
* [🎥 Tutorials & guides](https://github.com/bobthecow/psysh/wiki/Tutorials)
* [🐛 Troubleshooting](https://github.com/bobthecow/psysh/wiki/Troubleshooting)
### [📢 Commands](https://github.com/bobthecow/psysh/wiki/Commands)
### [🛠 Configuration](https://github.com/bobthecow/psysh/wiki/Configuration)
* [🎛 Config options](https://github.com/bobthecow/psysh/wiki/Config-options)
* [🎨 Themes](https://github.com/bobthecow/psysh/wiki/Themes)
* [📄 Sample config file](https://github.com/bobthecow/psysh/wiki/Sample-config)
### [🔌 Integrations](https://github.com/bobthecow/psysh/wiki/Integrations)

159
vendor/psy/psysh/bin/fetch-manual vendored Executable file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env php
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
// Fetch the latest manual from GitHub releases for bundling in PHAR builds
const RELEASES_URL = 'https://api.github.com/repos/bobthecow/psysh-manual/releases';
function fetchLatestManual(): bool {
echo "Fetching latest manual release info...\n";
$context = stream_context_create([
'http' => [
'user_agent' => 'PsySH Manual Fetcher',
'timeout' => 10.0,
],
]);
$result = @file_get_contents(RELEASES_URL, false, $context);
if (!$result) {
echo "Failed to fetch releases from GitHub\n";
return false;
}
$releases = json_decode($result, true);
if (!$releases || !is_array($releases)) {
echo "Invalid response from GitHub releases API\n";
return false;
}
// Find the first release with a manifest
foreach ($releases as $release) {
$manifest = fetchManifest($release, $context);
if ($manifest === null) {
continue;
}
// Find English PHP format manual in the manifest
foreach ($manifest['manuals'] as $manual) {
if ($manual['lang'] === 'en' && $manual['format'] === 'php') {
echo "Found manual v{$manual['version']} (en, php format)\n";
// Find the download URL for the manual
$filename = sprintf('psysh-manual-v%s-en.tar.gz', $manual['version']);
$downloadUrl = null;
foreach ($release['assets'] as $asset) {
if ($asset['name'] === $filename) {
$downloadUrl = $asset['browser_download_url'];
break;
}
}
if ($downloadUrl === null) {
echo "Could not find download URL for $filename\n";
return false;
}
// Download and extract the manual
return downloadAndExtractManual($downloadUrl, $filename, $context);
}
}
}
echo "No English PHP manual found in releases\n";
return false;
}
function fetchManifest(array $release, $context): ?array {
foreach ($release['assets'] as $asset) {
if ($asset['name'] === 'manifest.json') {
$manifestContent = @file_get_contents($asset['browser_download_url'], false, $context);
if ($manifestContent) {
return json_decode($manifestContent, true);
}
}
}
return null;
}
function downloadAndExtractManual(string $downloadUrl, string $filename, $context): bool {
echo "Downloading $filename...\n";
$tempFile = sys_get_temp_dir() . '/' . $filename;
$content = @file_get_contents($downloadUrl, false, $context);
if (!$content) {
echo "Failed to download manual\n";
return false;
}
if (!file_put_contents($tempFile, $content)) {
echo "Failed to save manual to $tempFile\n";
return false;
}
echo "Extracting manual...\n";
// Create temp directory for extraction
$tempDir = sys_get_temp_dir() . '/psysh-manual-' . uniqid();
if (!mkdir($tempDir)) {
echo "Failed to create temp directory\n";
unlink($tempFile);
return false;
}
try {
// Extract using PharData
$phar = new PharData($tempFile);
$phar->extractTo($tempDir);
// Find the php_manual.php file
$extractedFile = $tempDir . '/php_manual.php';
if (!file_exists($extractedFile)) {
echo "php_manual.php not found in extracted archive\n";
return false;
}
// Copy to current directory
if (!copy($extractedFile, 'php_manual.php')) {
echo "Failed to copy manual to current directory\n";
return false;
}
echo "Successfully fetched manual to php_manual.php\n";
return true;
} catch (Exception $e) {
echo "Failed to extract manual: " . $e->getMessage() . "\n";
return false;
} finally {
// Clean up
@unlink($tempFile);
removeDirectory($tempDir);
}
}
function removeDirectory(string $dir) {
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? removeDirectory($path) : unlink($path);
}
rmdir($dir);
}
// Main execution
exit(fetchLatestManual() ? 0 : 1);

148
vendor/psy/psysh/bin/psysh vendored Executable file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env php
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
// Try to find an autoloader for a local psysh version.
// We'll wrap this whole mess in a Closure so it doesn't leak any globals.
call_user_func(function () {
$cwd = null;
// Find the cwd arg (if present)
$argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : array();
foreach ($argv as $i => $arg) {
if ($arg === '--cwd') {
if ($i >= count($argv) - 1) {
fwrite(STDERR, 'Missing --cwd argument.' . PHP_EOL);
exit(1);
}
$cwd = $argv[$i + 1];
break;
}
if (preg_match('/^--cwd=/', $arg)) {
$cwd = substr($arg, 6);
break;
}
}
// Or fall back to the actual cwd
if (!isset($cwd)) {
$cwd = getcwd();
}
$cwd = str_replace('\\', '/', $cwd);
$chunks = explode('/', $cwd);
while (!empty($chunks)) {
$path = implode('/', $chunks);
$prettyPath = $path;
if (isset($_SERVER['HOME']) && $_SERVER['HOME']) {
$prettyPath = preg_replace('/^' . preg_quote($_SERVER['HOME'], '/') . '/', '~', $path);
}
// Find composer.json
if (is_file($path . '/composer.json')) {
if ($cfg = json_decode(file_get_contents($path . '/composer.json'), true)) {
if (isset($cfg['name']) && $cfg['name'] === 'psy/psysh') {
// We're inside the psysh project. Let's use the local Composer autoload.
if (is_file($path . '/vendor/autoload.php')) {
if (realpath($path) !== realpath(__DIR__ . '/..')) {
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
}
require $path . '/vendor/autoload.php';
}
return;
}
}
}
// Or a composer.lock
if (is_file($path . '/composer.lock')) {
if ($cfg = json_decode(file_get_contents($path . '/composer.lock'), true)) {
foreach (array_merge($cfg['packages'], $cfg['packages-dev']) as $pkg) {
if (isset($pkg['name']) && $pkg['name'] === 'psy/psysh') {
// We're inside a project which requires psysh. We'll use the local Composer autoload.
if (is_file($path . '/vendor/autoload.php')) {
if (realpath($path . '/vendor') !== realpath(__DIR__ . '/../../..')) {
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
}
require $path . '/vendor/autoload.php';
}
return;
}
}
}
}
array_pop($chunks);
}
});
// We didn't find an autoloader for a local version, so use the autoloader that
// came with this script.
if (!class_exists('Psy\Shell')) {
/* <<< */
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (is_file(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} else {
fwrite(STDERR, 'PsySH dependencies not found, be sure to run `composer install`.' . PHP_EOL);
fwrite(STDERR, 'See https://getcomposer.org to get Composer.' . PHP_EOL);
exit(1);
}
/* >>> */
}
// If the psysh binary was included directly, assume they just wanted an
// autoloader and bail early.
//
// Keep this PHP 5.3 and 5.4 code around for a while in case someone is using a
// globally installed psysh as a bin launcher for older local versions.
if (PHP_VERSION_ID < 50306) {
$trace = debug_backtrace();
} elseif (PHP_VERSION_ID < 50400) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
} else {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
}
if (Psy\Shell::isIncluded($trace)) {
unset($trace);
return;
}
// Clean up after ourselves.
unset($trace);
// If the local version is too old, we can't do this
if (!function_exists('Psy\bin')) {
$argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : array();
$first = array_shift($argv);
if (preg_match('/php(\.exe)?$/', $first)) {
array_shift($argv);
}
array_unshift($argv, 'vendor/bin/psysh');
fwrite(STDERR, 'A local PsySH dependency was found, but it cannot be loaded. Please update to' . PHP_EOL);
fwrite(STDERR, 'the latest version, or run the local copy directly, e.g.:' . PHP_EOL);
fwrite(STDERR, PHP_EOL);
fwrite(STDERR, ' ' . implode(' ', $argv) . PHP_EOL);
exit(1);
}
// And go!
call_user_func(Psy\bin());

37
vendor/psy/psysh/build/composer.json vendored Normal file
View File

@ -0,0 +1,37 @@
{
"name": "psy/psysh-build",
"description": "Build configuration for PsySH phar distribution.",
"type": "project",
"license": "MIT",
"require": {
"php": "^8.0 || ^7.4",
"ext-json": "*",
"ext-tokenizer": "*",
"composer/class-map-generator": "^1.0",
"nikic/php-parser": "^5.0 || ^4.0",
"symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
"symfony/polyfill-iconv": "^1.0",
"symfony/polyfill-mbstring": "^1.0",
"symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4"
},
"require-dev": {
"roave/security-advisories": "dev-latest"
},
"autoload": {
"files": ["src/functions.php"],
"psr-4": {
"Psy\\": "src/"
}
},
"bin": ["bin/psysh"],
"config": {
"platform": {
"php": "7.4.99"
},
"preferred-install": "dist",
"sort-packages": true
},
"conflict": {
"symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
}
}

2349
vendor/psy/psysh/build/composer.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

60
vendor/psy/psysh/composer.json vendored Normal file
View File

@ -0,0 +1,60 @@
{
"name": "psy/psysh",
"description": "An interactive shell for modern PHP.",
"type": "library",
"keywords": ["console", "interactive", "shell", "repl"],
"homepage": "https://psysh.org",
"license": "MIT",
"authors": [
{
"name": "Justin Hileman",
"email": "justin@justinhileman.info"
}
],
"require": {
"php": "^8.0 || ^7.4",
"ext-json": "*",
"ext-tokenizer": "*",
"nikic/php-parser": "^5.0 || ^4.0",
"symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
"symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.2",
"composer/class-map-generator": "^1.6"
},
"suggest": {
"ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)",
"ext-posix": "If you have PCNTL, you'll want the POSIX extension as well.",
"composer/class-map-generator": "Improved tab completion performance with better class discovery."
},
"autoload": {
"files": ["src/functions.php"],
"psr-4": {
"Psy\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Psy\\Test\\": "test/"
}
},
"bin": ["bin/psysh"],
"config": {
"allow-plugins": {
"bamarni/composer-bin-plugin": true
}
},
"extra": {
"branch-alias": {
"dev-main": "0.12.x-dev"
},
"bamarni-bin": {
"bin-links": false,
"forward-command": false
}
},
"conflict": {
"symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
}
}

542
vendor/psy/psysh/src/CodeCleaner.php vendored Normal file
View File

@ -0,0 +1,542 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\CodeCleaner\AbstractClassPass;
use Psy\CodeCleaner\AssignThisVariablePass;
use Psy\CodeCleaner\CalledClassPass;
use Psy\CodeCleaner\CallTimePassByReferencePass;
use Psy\CodeCleaner\CodeCleanerPass;
use Psy\CodeCleaner\EmptyArrayDimFetchPass;
use Psy\CodeCleaner\ExitPass;
use Psy\CodeCleaner\FinalClassPass;
use Psy\CodeCleaner\FunctionContextPass;
use Psy\CodeCleaner\FunctionReturnInWriteContextPass;
use Psy\CodeCleaner\ImplicitReturnPass;
use Psy\CodeCleaner\ImplicitUsePass;
use Psy\CodeCleaner\IssetPass;
use Psy\CodeCleaner\LabelContextPass;
use Psy\CodeCleaner\LeavePsyshAlonePass;
use Psy\CodeCleaner\ListPass;
use Psy\CodeCleaner\LoopContextPass;
use Psy\CodeCleaner\MagicConstantsPass;
use Psy\CodeCleaner\NamespaceAwarePass;
use Psy\CodeCleaner\NamespacePass;
use Psy\CodeCleaner\PassableByReferencePass;
use Psy\CodeCleaner\RequirePass;
use Psy\CodeCleaner\ReturnTypePass;
use Psy\CodeCleaner\StrictTypesPass;
use Psy\CodeCleaner\UseStatementPass;
use Psy\CodeCleaner\ValidClassNamePass;
use Psy\CodeCleaner\ValidConstructorPass;
use Psy\CodeCleaner\ValidFunctionNamePass;
use Psy\Exception\ParseErrorException;
use Psy\Util\Str;
/**
* A service to clean up user input, detect parse errors before they happen,
* and generally work around issues with the PHP code evaluation experience.
*/
class CodeCleaner
{
private bool $yolo = false;
private bool $strictTypes = false;
private $implicitUse = false;
private Parser $parser;
private Printer $printer;
private NodeTraverser $traverser;
private ?array $namespace = null;
private array $messages = [];
private array $aliasesByNamespace = [];
/**
* CodeCleaner constructor.
*
* @param Parser|null $parser A PhpParser Parser instance. One will be created if not explicitly supplied
* @param Printer|null $printer A PhpParser Printer instance. One will be created if not explicitly supplied
* @param NodeTraverser|null $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied
* @param bool $yolo run without input validation
* @param bool $strictTypes enforce strict types by default
* @param false|array $implicitUse disable implicit use statements (false) or configure with namespace filters (array)
*/
public function __construct(?Parser $parser = null, ?Printer $printer = null, ?NodeTraverser $traverser = null, bool $yolo = false, bool $strictTypes = false, $implicitUse = false)
{
$this->yolo = $yolo;
$this->strictTypes = $strictTypes;
$this->implicitUse = \is_array($implicitUse) ? $implicitUse : false;
$this->parser = $parser ?? (new ParserFactory())->createParser();
$this->printer = $printer ?: new Printer();
$this->traverser = $traverser ?: new NodeTraverser();
// Try to add implicit `use` statements and an implicit namespace, based on the file in
// which the `debug` call was made.
$this->addImplicitDebugContext();
foreach ($this->getDefaultPasses() as $pass) {
$this->traverser->addVisitor($pass);
// Set CodeCleaner instance on NamespaceAwarePass for state management
if ($pass instanceof NamespaceAwarePass) {
$pass->setCleaner($this);
}
}
}
/**
* Check whether this CodeCleaner is in YOLO mode.
*/
public function yolo(): bool
{
return $this->yolo;
}
/**
* Get default CodeCleaner passes.
*
* @return CodeCleanerPass[]
*/
private function getDefaultPasses(): array
{
// Add implicit use pass if enabled (must run before use statement pass)
$usePasses = [new UseStatementPass()];
if ($this->implicitUse) {
\array_unshift($usePasses, new ImplicitUsePass($this->implicitUse, $this));
}
// A set of code cleaner passes that don't try to do any validation, and
// only do minimal rewriting to make things work inside the REPL.
//
// When in --yolo mode, these are the only code cleaner passes used.
$rewritePasses = [
new LeavePsyshAlonePass(),
new ExitPass(),
new ImplicitReturnPass(),
new MagicConstantsPass(),
new NamespacePass(), // must run after the implicit return pass
...$usePasses, // must run after the namespace pass has re-injected the current namespace
new RequirePass(),
new StrictTypesPass($this->strictTypes),
];
if ($this->yolo) {
return $rewritePasses;
}
return [
// Validation passes
new AbstractClassPass(),
new AssignThisVariablePass(),
new CalledClassPass(),
new CallTimePassByReferencePass(),
new FinalClassPass(),
new FunctionContextPass(),
new FunctionReturnInWriteContextPass(),
new IssetPass(),
new LabelContextPass(),
new ListPass(),
new LoopContextPass(),
new PassableByReferencePass(),
new ReturnTypePass(),
new EmptyArrayDimFetchPass(),
new ValidConstructorPass(),
// Rewriting shenanigans
...$rewritePasses,
// Namespace-aware validation (which depends on aforementioned shenanigans)
new ValidClassNamePass(),
new ValidFunctionNamePass(),
];
}
/**
* "Warm up" code cleaner passes when we're coming from a debug call.
*
* This sets up the alias and namespace state that `UseStatementPass` and `NamespacePass` need
* to track between calls.
*/
private function addImplicitDebugContext()
{
$file = $this->getDebugFile();
if ($file === null) {
return;
}
try {
$code = @\file_get_contents($file);
if (!$code) {
return;
}
$stmts = $this->parse($code, true);
if ($stmts === false) {
return;
}
$useStatementPass = new UseStatementPass();
$useStatementPass->setCleaner($this);
$namespacePass = new NamespacePass();
$namespacePass->setCleaner($this);
// Set up a clean traverser for just these code cleaner passes
// @todo Pass visitors directly to once we drop support for PHP-Parser 4.x
$traverser = new NodeTraverser();
$traverser->addVisitor($useStatementPass);
$traverser->addVisitor($namespacePass);
$traverser->traverse($stmts);
} catch (\Throwable $e) {
// Don't care.
}
}
/**
* Search the stack trace for a file in which the user called Psy\debug.
*
* @return string|null
*/
private static function getDebugFile()
{
$trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);
foreach (\array_reverse($trace) as $stackFrame) {
if (!self::isDebugCall($stackFrame)) {
continue;
}
if (\preg_match('/eval\(/', $stackFrame['file'])) {
\preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
return $matches[1][0];
}
return $stackFrame['file'];
}
return null;
}
/**
* Check whether a given backtrace frame is a call to Psy\debug.
*
* @param array $stackFrame
*/
private static function isDebugCall(array $stackFrame): bool
{
$class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
$function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
return ($class === null && $function === 'Psy\\debug') ||
($class === Shell::class && $function === 'debug');
}
/**
* Clean the given array of code.
*
* @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
*
* @param array $codeLines
* @param bool $requireSemicolons
*
* @return string|false Cleaned PHP code, False if the input is incomplete
*/
public function clean(array $codeLines, bool $requireSemicolons = false)
{
// Clear messages from previous clean
$this->messages = [];
$stmts = $this->parse('<?php '.\implode(\PHP_EOL, $codeLines).\PHP_EOL, $requireSemicolons);
if ($stmts === false) {
return false;
}
// Catch fatal errors before they happen
$stmts = $this->traverser->traverse($stmts);
// Work around https://github.com/nikic/PHP-Parser/issues/399
$oldLocale = \setlocale(\LC_NUMERIC, 0);
\setlocale(\LC_NUMERIC, 'C');
$code = $this->printer->prettyPrint($stmts);
// Now put the locale back
\setlocale(\LC_NUMERIC, $oldLocale);
return $code;
}
/**
* Set the current local namespace.
*
* TODO: switch $this->namespace over to storing ?Name at some point!
*
* @param Name|array|null $namespace Namespace as Name node, array of parts, or null
*/
public function setNamespace($namespace = null)
{
if ($namespace instanceof Name) {
// Backwards compatibility shim for PHP-Parser 4.x
$namespace = \method_exists($namespace, 'getParts') ? $namespace->getParts() : $namespace->parts;
}
$this->namespace = $namespace;
}
/**
* Get the current local namespace.
*
* @return array|null
*/
public function getNamespace()
{
return $this->namespace;
}
/**
* Set use statement aliases for a specific namespace.
*
* @param Name|null $namespace Namespace name or Name node (null for global namespace)
* @param array $aliases Map of lowercase alias names to Name nodes
*/
public function setAliasesForNamespace(?Name $namespace, array $aliases)
{
$namespaceKey = \strtolower($namespace ? $namespace->toString() : '');
$this->aliasesByNamespace[$namespaceKey] = $aliases;
}
/**
* Get use statement aliases for a specific namespace.
*
* (This currently accepts a string namespace name, because that's all we're storing in
* CodeCleaner as the current namespace; we should update that to be a Name node.)
*
* @param Name|string|null $namespace Namespace name or Name node (null for global namespace)
*
* @return array Map of lowercase alias names to Name nodes
*/
public function getAliasesForNamespace($namespace): array
{
$namespaceName = $namespace instanceof Name ? $namespace->toString() : $namespace;
$namespaceKey = \strtolower($namespaceName ?? '');
return $this->aliasesByNamespace[$namespaceKey] ?? [];
}
/**
* Resolve a class name using current use statements and namespace.
*
* This is used by commands to resolve short names the same way code execution does.
* Uses PHP-Parser's NameResolver along with PsySH's custom passes.
*
* @param string $name Class name to resolve (e.g., "NoopChecker" or "Bar\Baz")
*
* @return string Resolved class name (may be FQN, or original name if no resolution found)
*/
public function resolveClassName(string $name): string
{
// Clear messages from previous resolution
$this->messages = [];
// Only attempt resolution if it's a valid class name, and not already fully qualified
if (\substr($name, 0, 1) === '\\' || !Str::isValidClassName($name)) {
return $name;
}
try {
// Parse as a class name constant
$stmts = $this->parser->parse('<?php '.$name.'::class;');
// Create fresh passes for name resolution. They read state from $this.
$namespacePass = new NamespacePass();
$namespacePass->setCleaner($this);
$useStatementPass = new UseStatementPass();
$useStatementPass->setCleaner($this);
// Create a fresh traverser with fresh passes
$traverser = new NodeTraverser();
$traverser->addVisitor($namespacePass);
$traverser->addVisitor($useStatementPass);
// Add PHP-Parser's NameResolver - preserveOriginalNames lets us detect when resolution occurred
$traverser->addVisitor(new NameResolver(null, [
'preserveOriginalNames' => true,
]));
// Traverse: NamespacePass wraps in namespace if needed,
// UseStatementPass re-injects use statements,
// PHP-Parser's NameResolver resolves to FullyQualified
$stmts = $traverser->traverse($stmts);
// Find the Expression node - it might be after re-injected use statements
// or wrapped in a Namespace_ node
$targetStmt = null;
foreach ($stmts as $stmt) {
if ($stmt instanceof Namespace_) {
// Look inside the namespace for the Expression
foreach ($stmt->stmts ?? [] as $innerStmt) {
if ($innerStmt instanceof Expression) {
$targetStmt = $innerStmt;
break 2;
}
}
} elseif ($stmt instanceof Expression) {
$targetStmt = $stmt;
break;
}
}
if ($targetStmt instanceof Expression) {
$expr = $targetStmt->expr;
if ($expr instanceof ClassConstFetch && $expr->class instanceof FullyQualified) {
$resolved = '\\'.$expr->class->toString();
// Check if actual resolution occurred by comparing original to resolved
// NameResolver preserves the original Name node in the 'originalName' attribute
$originalName = $expr->class->getAttribute('originalName');
if ($originalName instanceof Name) {
$originalStr = $originalName->toString();
$resolvedStr = $expr->class->toString();
// If they differ, resolution occurred (use statement was applied)
if ($originalStr !== $resolvedStr) {
return $resolved;
}
}
// No transformation occurred - return original name unchanged
return $name;
}
}
} catch (\Throwable $e) {
// Fall through to return original name
}
return $name;
}
/**
* Log a message from a CodeCleaner pass.
*
* @param string $message Message text to display
*/
public function log(string $message): void
{
$this->messages[] = $message;
}
/**
* Get all logged messages from the last clean operation.
*
* @return string[] Array of message strings
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Lex and parse a block of code.
*
* @see Parser::parse
*
* @throws ParseErrorException for parse errors that can't be resolved by
* waiting a line to see what comes next
*
* @return array|false A set of statements, or false if incomplete
*/
protected function parse(string $code, bool $requireSemicolons = false)
{
try {
return $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
if ($this->parseErrorIsUnclosedString($e, $code)) {
return false;
}
if ($this->parseErrorIsUnterminatedComment($e, $code)) {
return false;
}
if ($this->parseErrorIsTrailingComma($e, $code)) {
return false;
}
if (!$this->parseErrorIsEOF($e)) {
throw ParseErrorException::fromParseError($e);
}
if ($requireSemicolons) {
return false;
}
try {
// Unexpected EOF, try again with an implicit semicolon
return $this->parser->parse($code.';');
} catch (\PhpParser\Error $e) {
return false;
}
}
}
private function parseErrorIsEOF(\PhpParser\Error $e): bool
{
$msg = $e->getRawMessage();
return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);
}
/**
* A special test for unclosed single-quoted strings.
*
* Unlike (all?) other unclosed statements, single quoted strings have
* their own special beautiful snowflake syntax error just for
* themselves.
*/
private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code): bool
{
if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
return false;
}
try {
$this->parser->parse($code."';");
} catch (\Throwable $e) {
return false;
}
return true;
}
private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, string $code): bool
{
return $e->getRawMessage() === 'Unterminated comment';
}
private function parseErrorIsTrailingComma(\PhpParser\Error $e, string $code): bool
{
return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (\substr(\rtrim($code), -1) === ',');
}
}

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use Psy\Exception\FatalErrorException;
/**
* The abstract class pass handles abstract classes and methods, complaining if there are too few or too many of either.
*/
class AbstractClassPass extends CodeCleanerPass
{
private Class_ $class;
private array $abstractMethods;
/**
* @throws FatalErrorException if the node is an abstract function with a body
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_) {
$this->class = $node;
$this->abstractMethods = [];
} elseif ($node instanceof ClassMethod) {
if ($node->isAbstract()) {
$name = \sprintf('%s::%s', $this->class->name, $node->name);
$this->abstractMethods[] = $name;
if ($node->stmts !== null) {
$msg = \sprintf('Abstract function %s cannot contain body', $name);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
return null;
}
/**
* @throws FatalErrorException if the node is a non-abstract class with abstract methods
*
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Class_) {
$count = \count($this->abstractMethods);
if ($count > 0 && !$node->isAbstract()) {
$msg = \sprintf(
'Class %s contains %d abstract method%s must therefore be declared abstract or implement the remaining methods (%s)',
$node->name,
$count,
($count === 1) ? '' : 's',
\implode(', ', $this->abstractMethods)
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\FatalErrorException;
/**
* Validate that the user input does not assign the `$this` variable.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class AssignThisVariablePass extends CodeCleanerPass
{
/**
* Validate that the user input does not assign the `$this` variable.
*
* @throws FatalErrorException if the user assign the `$this` variable
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Assign && $node->var instanceof Variable && $node->var->name === 'this') {
throw new FatalErrorException('Cannot re-assign $this', 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that the user did not use the call-time pass-by-reference that causes a fatal error.
*
* As of PHP 5.4.0, call-time pass-by-reference was removed, so using it will raise a fatal error.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class CallTimePassByReferencePass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Call-time pass-by-reference has been removed';
/**
* Validate of use call-time pass-by-reference.
*
* @throws FatalErrorException if the user used call-time pass-by-reference
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof FuncCall && !$node instanceof MethodCall && !$node instanceof StaticCall) {
return null;
}
foreach ($node->args as $arg) {
if ($arg instanceof VariadicPlaceholder) {
continue;
}
if ($arg->byRef) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@ -0,0 +1,104 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\ErrorException;
/**
* The called class pass throws warnings for get_class() and get_called_class()
* outside a class context.
*/
class CalledClassPass extends CodeCleanerPass
{
private bool $inClass = false;
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->inClass = false;
return null;
}
/**
* @throws ErrorException if get_class or get_called_class is called without an object from outside a class
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_ || $node instanceof Trait_) {
$this->inClass = true;
} elseif ($node instanceof FuncCall && !$this->inClass) {
// We'll give any args at all (besides null) a pass.
// Technically we should be checking whether the args are objects, but this will do for now.
//
// @todo switch this to actually validate args when we get context-aware code cleaner passes.
if (!empty($node->args) && !$this->isNull($node->args[0])) {
return null;
}
// We'll ignore name expressions as well (things like `$foo()`)
if (!($node->name instanceof Name)) {
return null;
}
$name = \strtolower($node->name);
if (\in_array($name, ['get_class', 'get_called_class'])) {
$msg = \sprintf('%s() called without object from outside a class', $name);
throw new ErrorException($msg, 0, \E_USER_WARNING, null, $node->getStartLine());
}
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Class_) {
$this->inClass = false;
}
return null;
}
private function isNull(Node $node): bool
{
if ($node instanceof VariadicPlaceholder) {
return false;
}
if (!\property_exists($node, 'value')) {
return false;
}
return $node->value instanceof ConstFetch && \strtolower($node->value->name) === 'null';
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\NodeVisitorAbstract;
/**
* A CodeCleaner pass is a PhpParser Node Visitor.
*/
abstract class CodeCleanerPass extends NodeVisitorAbstract
{
// Wheee!
}

View File

@ -0,0 +1,70 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\AssignRef;
use PhpParser\Node\Stmt\Foreach_;
use Psy\Exception\FatalErrorException;
/**
* Validate empty brackets are only used for assignment.
*/
class EmptyArrayDimFetchPass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Cannot use [] for reading';
private array $theseOnesAreFine = [];
/**
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->theseOnesAreFine = [];
return null;
}
/**
* @throws FatalErrorException if the user used empty array dim fetch outside of assignment
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Assign && $node->var instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->var;
} elseif ($node instanceof AssignRef && $node->expr instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->expr;
} elseif ($node instanceof Foreach_ && $node->valueVar instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->valueVar;
} elseif ($node instanceof ArrayDimFetch && $node->var instanceof ArrayDimFetch) {
// $a[]['b'] = 'c'
if (\in_array($node, $this->theseOnesAreFine)) {
$this->theseOnesAreFine[] = $node->var;
}
}
if ($node instanceof ArrayDimFetch && $node->dim === null) {
if (!\in_array($node, $this->theseOnesAreFine)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, $node->getStartLine());
}
}
return null;
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use Psy\Exception\BreakException;
class ExitPass extends CodeCleanerPass
{
/**
* Converts exit calls to BreakExceptions.
*
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Exit_) {
$args = $node->expr ? [new Arg($node->expr)] : [];
return new StaticCall(new FullyQualifiedName(BreakException::class), 'exitShell', $args);
}
return null;
}
}

View File

@ -0,0 +1,76 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use Psy\Exception\FatalErrorException;
/**
* The final class pass handles final classes.
*/
class FinalClassPass extends CodeCleanerPass
{
private array $finalClasses = [];
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->finalClasses = [];
return null;
}
/**
* @throws FatalErrorException if the node is a class that extends a final class
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_) {
if ($node->extends) {
$extends = (string) $node->extends;
if ($this->isFinalClass($extends)) {
$msg = \sprintf('Class %s may not inherit from final class (%s)', $node->name, $extends);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
if ($node->isFinal()) {
$this->finalClasses[\strtolower($node->name)] = true;
}
}
return null;
}
/**
* @param string $name Class name
*/
private function isFinalClass(string $name): bool
{
if (!\class_exists($name)) {
return isset($this->finalClasses[\strtolower($name)]);
}
$refl = new \ReflectionClass($name);
return $refl->isFinal();
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Yield_;
use PhpParser\Node\FunctionLike;
use Psy\Exception\FatalErrorException;
class FunctionContextPass extends CodeCleanerPass
{
private int $functionDepth = 0;
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->functionDepth = 0;
return null;
}
/**
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth++;
return null;
}
// node is inside function context
if ($this->functionDepth !== 0) {
return null;
}
// It causes fatal error.
if ($node instanceof Yield_) {
$msg = 'The "yield" expression can only be used inside a function';
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
/**
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth--;
}
return null;
}
}

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Isset_;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Stmt\Unset_;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that the functions are used correctly.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class FunctionReturnInWriteContextPass extends CodeCleanerPass
{
const ISSET_MESSAGE = 'Cannot use isset() on the result of an expression (you can use "null !== expression" instead)';
const EXCEPTION_MESSAGE = "Can't use function return value in write context";
/**
* Validate that the functions are used correctly.
*
* @throws FatalErrorException if a function is passed as an argument reference
* @throws FatalErrorException if a function is used as an argument in the isset
* @throws FatalErrorException if a value is assigned to a function
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Array_ || $this->isCallNode($node)) {
$items = $node instanceof Array_ ? $node->items : $node->args;
foreach ($items as $item) {
if ($item instanceof VariadicPlaceholder) {
continue;
}
if ($item && $item->byRef && $this->isCallNode($item->value)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
} elseif ($node instanceof Isset_ || $node instanceof Unset_) {
foreach ($node->vars as $var) {
if (!$this->isCallNode($var)) {
continue;
}
$msg = $node instanceof Isset_ ? self::ISSET_MESSAGE : self::EXCEPTION_MESSAGE;
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
} elseif ($node instanceof Assign && $this->isCallNode($node->var)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
private function isCallNode(Node $node): bool
{
return $node instanceof FuncCall || $node instanceof MethodCall || $node instanceof StaticCall;
}
}

View File

@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Break_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\Switch_;
/**
* Add an implicit "return" to the last statement, provided it can be returned.
*/
class ImplicitReturnPass extends CodeCleanerPass
{
/**
* @param array $nodes
*
* @return array
*/
public function beforeTraverse(array $nodes): array
{
return $this->addImplicitReturn($nodes);
}
/**
* @param array $nodes
*
* @return array
*/
private function addImplicitReturn(array $nodes): array
{
// If nodes is empty, it can't have a return value.
if (empty($nodes)) {
return [new Return_(NoReturnValue::create())];
}
$last = \end($nodes);
// Special case a few types of statements to add an implicit return
// value (even though they technically don't have any return value)
// because showing a return value in these instances is useful and not
// very surprising.
if ($last instanceof If_) {
$last->stmts = $this->addImplicitReturn($last->stmts);
foreach ($last->elseifs as $elseif) {
$elseif->stmts = $this->addImplicitReturn($elseif->stmts);
}
if ($last->else) {
$last->else->stmts = $this->addImplicitReturn($last->else->stmts);
}
} elseif ($last instanceof Switch_) {
foreach ($last->cases as $case) {
// only add an implicit return to cases which end in break
$caseLast = \end($case->stmts);
if ($caseLast instanceof Break_) {
$case->stmts = $this->addImplicitReturn(\array_slice($case->stmts, 0, -1));
$case->stmts[] = $caseLast;
}
}
} elseif ($last instanceof Expr && !($last instanceof Exit_)) {
// @codeCoverageIgnoreStart
$nodes[\count($nodes) - 1] = new Return_($last, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
// @codeCoverageIgnoreEnd
} elseif ($last instanceof Expression && !($last->expr instanceof Exit_)) {
$nodes[\count($nodes) - 1] = new Return_($last->expr, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
} elseif ($last instanceof Namespace_) {
$last->stmts = $this->addImplicitReturn($last->stmts);
}
// Return a "no return value" for all non-expression statements, so that
// PsySH can suppress the `null` that `eval()` returns otherwise.
//
// Note that statements special cased above (if/elseif/else, switch)
// _might_ implicitly return a value before this catch-all return is
// reached.
//
// We're not adding a fallback return after namespace statements,
// because code outside namespace statements doesn't really work, and
// there's already an implicit return in the namespace statement anyway.
if (self::isNonExpressionStmt($last)) {
$nodes[] = new Return_(NoReturnValue::create());
}
return $nodes;
}
/**
* Check whether a given node is a non-expression statement.
*
* As of PHP Parser 4.x, Expressions are now instances of Stmt as well, so
* we'll exclude them here.
*
* @param Node $node
*/
private static function isNonExpressionStmt(Node $node): bool
{
return $node instanceof Stmt &&
!$node instanceof Expression &&
!$node instanceof Return_ &&
!$node instanceof Namespace_;
}
}

View File

@ -0,0 +1,396 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\UseItem;
use PhpParser\Node\Stmt\UseUse;
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use Psy\CodeCleaner;
/**
* Automatically add use statements for unqualified class references.
*
* When a user references a class by its short name (e.g., `User`), this pass attempts to find a
* fully-qualified class name that matches. A use statement is added if:
*
* - There is no unqualified name (class/function/constant) with that short name
* - There is no existing use statement or alias with that short name
* - There is exactly one matching class/interface/trait in the configured namespaces
*
* For example, in a project with `App\Model\User` and `App\View\User` classes, if configured with
* 'includeNamespaces' => ['App\Model'], `new User` would become `use App\Model\User; new User;`
* even though there's also an `App\View\User` class.
*
* Works great with autoload warming (--warm-autoload) to pre-load classes.
*/
class ImplicitUsePass extends CodeCleanerPass
{
private ?array $shortNameMap = null;
private array $implicitUses = [];
private array $seenNames = [];
private array $existingAliases = [];
private array $includeNamespaces = [];
private array $excludeNamespaces = [];
private ?string $currentNamespace = null;
private ?CodeCleaner $cleaner = null;
private ?PrettyPrinter $printer = null;
/**
* @param array $config Configuration array with 'includeNamespaces' and/or 'excludeNamespaces'
* @param CodeCleaner|null $cleaner CodeCleaner instance for logging
*/
public function __construct(array $config = [], ?CodeCleaner $cleaner = null)
{
$this->includeNamespaces = $this->normalizeNamespaces($config['includeNamespaces'] ?? []);
$this->excludeNamespaces = $this->normalizeNamespaces($config['excludeNamespaces'] ?? []);
$this->cleaner = $cleaner;
}
/**
* {@inheritdoc}
*/
public function beforeTraverse(array $nodes)
{
if (empty($this->includeNamespaces) && empty($this->excludeNamespaces)) {
return null;
}
$this->buildShortNameMap();
// Reset state for this traversal
$this->implicitUses = [];
$this->seenNames = [];
$this->existingAliases = [];
$this->currentNamespace = null;
$modified = false;
// Collect use statements and seen names for each namespace
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
$this->currentNamespace = $node->name ? $node->name->toString() : null;
$perNamespaceAliases = [];
$perNamespaceUses = [];
$perNamespaceSeen = [];
if ($node->stmts !== null) {
$this->collectAliasesInNodes($node->stmts, $perNamespaceAliases);
$this->collectNamesInNodes($node->stmts, $perNamespaceSeen, $perNamespaceAliases, $perNamespaceUses);
}
if (!empty($perNamespaceUses)) {
$this->logAddedUses($perNamespaceUses);
$node->stmts = \array_merge($this->createUseStatements($perNamespaceUses), $node->stmts ?? []);
$modified = true;
}
}
}
$hasNamespace = false;
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
$hasNamespace = true;
break;
}
}
// Collect use statements and seen names for top-level namespace
if (!$hasNamespace) {
$this->currentNamespace = null;
$topLevelAliases = [];
$topLevelUses = [];
$topLevelSeen = [];
$this->collectAliasesInNodes($nodes, $topLevelAliases);
$this->collectNamesInNodes($nodes, $topLevelSeen, $topLevelAliases, $topLevelUses);
if (!empty($topLevelUses)) {
$this->logAddedUses($topLevelUses);
return \array_merge($this->createUseStatements($topLevelUses), $nodes);
}
}
return $modified ? $nodes : null;
}
/**
* Collect aliases in a set of nodes.
*
* @param array $nodes Array of Node objects
* @param array $aliases Associative array mapping lowercase alias names to true
*/
private function collectAliasesInNodes(array $nodes, array &$aliases): void
{
foreach ($nodes as $node) {
if ($node instanceof Use_ || $node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$alias = $useItem->getAlias();
if ($alias !== null) {
$aliasStr = $alias instanceof Name ? $alias->toString() : (string) $alias;
$aliases[\strtolower($aliasStr)] = true;
} else {
$aliases[\strtolower($this->getShortName($useItem->name))] = true;
}
}
}
}
}
/**
* Collect unqualified names in nodes.
*
* @param array $nodes Array of Node objects to traverse
* @param array $seen Lowercase short names already processed
* @param array $aliases Lowercase alias names that exist in this namespace
* @param array $uses Map of short names to FQNs for implicit use statements
*/
private function collectNamesInNodes(array $nodes, array &$seen, array $aliases, array &$uses): void
{
foreach ($nodes as $node) {
if (!$node instanceof Node || $node instanceof Use_) {
continue;
}
if ($node instanceof Name && !$node instanceof FullyQualifiedName) {
if (!$this->isQualified($node)) {
$shortName = $this->getShortName($node);
$shortNameLower = \strtolower($shortName);
if (isset($seen[$shortNameLower])) {
continue;
}
$seen[$shortNameLower] = true;
if ($this->shouldAddImplicitUseInContext($shortName, $shortNameLower, $aliases)) {
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable - shortNameMap is initialized in beforeTraverse
$uses[$shortName] = $this->shortNameMap[$shortNameLower];
}
}
}
foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->$subNodeName;
if ($subNode instanceof Node) {
$subNode = [$subNode];
}
if (\is_array($subNode)) {
$this->collectNamesInNodes($subNode, $seen, $aliases, $uses);
}
}
}
}
/**
* Create Use_ statement nodes from uses array.
*
* @param array $uses Associative array mapping short names to FQNs
*
* @return Use_[]
*/
private function createUseStatements(array $uses): array
{
\asort($uses);
$useStatements = [];
foreach ($uses as $fqn) {
$useItem = \class_exists(UseItem::class) ? new UseItem(new Name($fqn)) : new UseUse(new Name($fqn));
$useStatements[] = new Use_([$useItem]);
}
return $useStatements;
}
/**
* Check if we should add an implicit use statement for this name in current context.
*
* @param string $shortName Original case short name
* @param string $shortNameLower Lowercase short name for comparison
* @param array $aliases Lowercase alias names that exist in this namespace
*/
private function shouldAddImplicitUseInContext(string $shortName, string $shortNameLower, array $aliases): bool
{
// Rule 1: No existing unqualified name (class/interface/trait) with that short name
if (\class_exists($shortName, false) || \interface_exists($shortName, false) || \trait_exists($shortName, false)) {
return false;
}
// Rule 2: No existing use statement or alias with that short name
if (isset($aliases[$shortNameLower])) {
return false;
}
// Rule 3: Exactly one matching short class/interface/trait in configured namespaces
if (!isset($this->shortNameMap[$shortNameLower]) || $this->shortNameMap[$shortNameLower] === null) {
return false;
}
// Rule 4: Don't add use statement if the class exists in the current namespace
if ($this->currentNamespace !== null) {
$expectedFqn = \trim($this->currentNamespace, '\\').'\\'.$shortName;
if (\class_exists($expectedFqn, false) || \interface_exists($expectedFqn, false) || \trait_exists($expectedFqn, false)) {
return false;
}
}
return true;
}
/**
* Build a map of short class names to fully-qualified names.
*
* Uses get_declared_classes(), get_declared_interfaces(), and get_declared_traits()
* to find all currently loaded classes. Only includes classes matching the configured
* namespace filters. Detects ambiguous short names (multiple FQNs with same short name
* within the filtered namespaces) and marks them as null.
*/
private function buildShortNameMap(): void
{
$this->shortNameMap = [];
$allClasses = \array_merge(
\get_declared_classes(),
\get_declared_interfaces(),
\get_declared_traits()
);
// First pass: collect all matching classes
$candidatesByShortName = [];
foreach ($allClasses as $fqn) {
if (!$this->shouldIncludeClass($fqn)) {
continue;
}
$parts = \explode('\\', $fqn);
$shortName = \strtolower(\end($parts));
if (!isset($candidatesByShortName[$shortName])) {
$candidatesByShortName[$shortName] = [];
}
$candidatesByShortName[$shortName][] = $fqn;
}
// Second pass: determine if each short name is unique or ambiguous
foreach ($candidatesByShortName as $shortName => $fqns) {
$uniqueFqns = \array_unique($fqns);
// Mark as null if ambiguous (multiple FQNs with same short name)
$this->shortNameMap[$shortName] = (\count($uniqueFqns) === 1) ? $uniqueFqns[0] : null;
}
}
/**
* Check if a class should be aliased based on namespace filters.
*
* @param string $fqn Fully-qualified class name
*/
private function shouldIncludeClass(string $fqn): bool
{
if (\strpos($fqn, '\\') === false) {
return false;
}
if (empty($this->includeNamespaces) && empty($this->excludeNamespaces)) {
return false;
}
foreach ($this->excludeNamespaces as $namespace) {
if (\stripos($fqn, $namespace) === 0) {
return false;
}
}
if (empty($this->includeNamespaces)) {
return true;
}
foreach ($this->includeNamespaces as $namespace) {
if (\stripos($fqn, $namespace) === 0) {
return true;
}
}
return false;
}
/**
* Normalize namespace prefixes.
*
* Removes leading backslash and ensures trailing backslash.
*
* @param string[] $namespaces
*
* @return string[]
*/
private function normalizeNamespaces(array $namespaces): array
{
return \array_map(function ($namespace) {
return \trim($namespace, '\\').'\\';
}, $namespaces);
}
/**
* Get short name from a Name node.
*/
private function getShortName(Name $name): string
{
$parts = $this->getParts($name);
return \end($parts);
}
/**
* Check if a name is qualified (contains namespace separator).
*/
private function isQualified(Name $name): bool
{
return \count($this->getParts($name)) > 1;
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* @return string[]
*/
private function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
/**
* Log added use statements to the CodeCleaner.
*
* @param array $uses Associative array mapping short names to FQNs
*/
private function logAddedUses(array $uses): void
{
if ($this->cleaner === null || empty($uses)) {
return;
}
if ($this->printer === null) {
$this->printer = new PrettyPrinter();
}
$useStmts = $this->createUseStatements($uses);
$this->cleaner->log($this->printer->prettyPrint($useStmts));
}
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\Isset_;
use PhpParser\Node\Expr\NullsafePropertyFetch;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\FatalErrorException;
/**
* Code cleaner pass to ensure we only allow variables, array fetch and property
* fetch expressions in isset() calls.
*/
class IssetPass extends CodeCleanerPass
{
const EXCEPTION_MSG = 'Cannot use isset() on the result of an expression (you can use "null !== expression" instead)';
/**
* @throws FatalErrorException
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof Isset_) {
return null;
}
foreach ($node->vars as $var) {
if (!$var instanceof Variable && !$var instanceof ArrayDimFetch && !$var instanceof PropertyFetch && !$var instanceof NullsafePropertyFetch) {
throw new FatalErrorException(self::EXCEPTION_MSG, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@ -0,0 +1,105 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\Goto_;
use PhpParser\Node\Stmt\Label;
use Psy\Exception\FatalErrorException;
/**
* CodeCleanerPass for label context.
*
* This class partially emulates the PHP label specification.
* PsySH can not declare labels by sequentially executing lines with eval,
* but since it is not a syntax error, no error is raised.
* This class warns before invalid goto causes a fatal error.
* Since this is a simple checker, it does not block real fatal error
* with complex syntax. (ex. it does not parse inside function.)
*
* @see http://php.net/goto
*/
class LabelContextPass extends CodeCleanerPass
{
private int $functionDepth = 0;
private array $labelDeclarations = [];
private array $labelGotos = [];
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->functionDepth = 0;
$this->labelDeclarations = [];
$this->labelGotos = [];
return null;
}
/**
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth++;
return null;
}
// node is inside function context
if ($this->functionDepth !== 0) {
return null;
}
if ($node instanceof Goto_) {
$this->labelGotos[\strtolower($node->name)] = $node->getStartLine();
} elseif ($node instanceof Label) {
$this->labelDeclarations[\strtolower($node->name)] = $node->getStartLine();
}
return null;
}
/**
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth--;
}
return null;
}
/**
* @return Node[]|null Array of nodes
*/
public function afterTraverse(array $nodes)
{
foreach ($this->labelGotos as $name => $line) {
if (!isset($this->labelDeclarations[$name])) {
$msg = "'goto' to undefined label '{$name}'";
throw new FatalErrorException($msg, 0, \E_ERROR, null, $line);
}
}
return null;
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\RuntimeException;
/**
* Validate that the user input does not reference the `$__psysh__` variable.
*/
class LeavePsyshAlonePass extends CodeCleanerPass
{
/**
* Validate that the user input does not reference the `$__psysh__` variable.
*
* @throws RuntimeException if the user is messing with $__psysh__
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Variable && $node->name === '__psysh__') {
throw new RuntimeException('Don\'t mess with $__psysh__; bad things will happen');
}
return null;
}
}

View File

@ -0,0 +1,97 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayDimFetch;
// @todo Drop PhpParser\Node\Expr\ArrayItem once we drop support for PHP-Parser 4.x
use PhpParser\Node\Expr\ArrayItem as LegacyArrayItem;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\List_;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\ParseErrorException;
/**
* Validate that the list assignment.
*/
class ListPass extends CodeCleanerPass
{
/**
* Validate use of list assignment.
*
* @throws ParseErrorException if the user used empty with anything but a variable
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof Assign) {
return null;
}
if (!$node->var instanceof Array_ && !$node->var instanceof List_) {
return null;
}
// Polyfill for PHP-Parser 2.x
$items = isset($node->var->items) ? $node->var->items : (\property_exists($node->var, 'vars') ? $node->var->vars : []);
if ($items === [] || $items === [null]) {
throw new ParseErrorException('Cannot use empty list', ['startLine' => $node->var->getStartLine(), 'endLine' => $node->var->getEndLine()]);
}
$itemFound = false;
foreach ($items as $item) {
if ($item === null) {
continue;
}
$itemFound = true;
if (!self::isValidArrayItem($item)) {
$msg = 'Assignments can only happen to writable values';
throw new ParseErrorException($msg, ['startLine' => $item->getStartLine(), 'endLine' => $item->getEndLine()]);
}
}
if (!$itemFound) {
throw new ParseErrorException('Cannot use empty list');
}
return null;
}
/**
* Validate whether a given item in an array is valid for short assignment.
*
* @param Node $item
*/
private static function isValidArrayItem(Node $item): bool
{
$value = ($item instanceof ArrayItem || $item instanceof LegacyArrayItem) ? $item->value : $item;
while ($value instanceof ArrayDimFetch || $value instanceof PropertyFetch) {
$value = $value->var;
}
// We just kind of give up if it's a method call. We can't tell if it's
// valid via static analysis.
return $value instanceof Variable || $value instanceof MethodCall || $value instanceof FuncCall;
}
}

View File

@ -0,0 +1,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Scalar\DNumber;
use PhpParser\Node\Scalar\Float_;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\Node\Stmt\Break_;
use PhpParser\Node\Stmt\Continue_;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* The loop context pass handles invalid `break` and `continue` statements.
*/
class LoopContextPass extends CodeCleanerPass
{
private int $loopDepth = 0;
/**
* {@inheritdoc}
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->loopDepth = 0;
return null;
}
/**
* @throws FatalErrorException if the node is a break or continue in a non-loop or switch context
* @throws FatalErrorException if the node is trying to break out of more nested structures than exist
* @throws FatalErrorException if the node is a break or continue and has a non-numeric argument
* @throws FatalErrorException if the node is a break or continue and has an argument less than 1
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
switch (true) {
case $node instanceof Do_:
case $node instanceof For_:
case $node instanceof Foreach_:
case $node instanceof Switch_:
case $node instanceof While_:
$this->loopDepth++;
break;
case $node instanceof Break_:
case $node instanceof Continue_:
$operator = $node instanceof Break_ ? 'break' : 'continue';
if ($this->loopDepth === 0) {
$msg = \sprintf("'%s' not in the 'loop' or 'switch' context", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
// @todo Remove LNumber and DNumber once we drop support for PHP-Parser 4.x
if (
$node->num instanceof LNumber ||
$node->num instanceof DNumber ||
$node->num instanceof Int_ ||
$node->num instanceof Float_
) {
$num = $node->num->value;
if ($node->num instanceof DNumber || $num < 1) {
$msg = \sprintf("'%s' operator accepts only positive numbers", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
if ($num > $this->loopDepth) {
$msg = \sprintf("Cannot '%s' %d levels", $operator, $num);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
} elseif ($node->num) {
$msg = \sprintf("'%s' operator with non-constant operand is no longer supported", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
break;
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
switch (true) {
case $node instanceof Do_:
case $node instanceof For_:
case $node instanceof Foreach_:
case $node instanceof Switch_:
case $node instanceof While_:
$this->loopDepth--;
break;
}
return null;
}
}

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\MagicConst\Dir;
use PhpParser\Node\Scalar\MagicConst\File;
use PhpParser\Node\Scalar\String_;
/**
* Swap out __DIR__ and __FILE__ magic constants with our best guess?
*/
class MagicConstantsPass extends CodeCleanerPass
{
/**
* Swap out __DIR__ and __FILE__ constants, because the default ones when
* calling eval() don't make sense.
*
* @param Node $node
*
* @return FuncCall|String_|null
*/
public function enterNode(Node $node)
{
if ($node instanceof Dir) {
return new FuncCall(new Name('getcwd'), [], $node->getAttributes());
} elseif ($node instanceof File) {
return new String_('', $node->getAttributes());
}
return null;
}
}

View File

@ -0,0 +1,169 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use Psy\CodeCleaner;
/**
* Abstract namespace-aware code cleaner pass.
*
* Tracks both namespace and use statement aliases for proper name resolution.
*/
abstract class NamespaceAwarePass extends CodeCleanerPass
{
protected array $namespace = [];
protected array $currentScope = [];
protected array $aliases = [];
protected ?CodeCleaner $cleaner = null;
/**
* Set the CodeCleaner instance for state management.
*/
public function setCleaner(CodeCleaner $cleaner)
{
$this->cleaner = $cleaner;
}
/**
* @todo should this be final? Extending classes should be sure to either
* use afterTraverse or call parent::beforeTraverse() when overloading.
*
* Reset the namespace and the current scope before beginning analysis
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->namespace = [];
$this->currentScope = [];
return null;
}
/**
* @todo should this be final? Extending classes should be sure to either use
* leaveNode or call parent::enterNode() when overloading
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Namespace_) {
$this->namespace = isset($node->name) ? $this->getParts($node->name) : [];
// Only restore use statement aliases for PsySH re-injected namespaces.
// Explicit namespace declarations start with a clean slate.
if ($this->cleaner && $node->getAttribute('psyshReinjected')) {
$this->aliases = $this->cleaner->getAliasesForNamespace($node->name);
} else {
$this->aliases = [];
}
}
// Track use statements for alias resolution
if ($node instanceof Use_) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = $useItem->name;
}
}
// Track group use statements
if ($node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = Name::concat($node->prefix, $useItem->name);
}
}
return null;
}
/**
* Save alias state when leaving a namespace.
*
* Braced namespaces (like `namespace { ... }`) are self-contained and don't persist their use
* statements between executions.
*
* Only save aliases for open namespaces (like `namespace Foo;`), or implicit namespace wrappers
* re-injected by PsySH (psyshReinjected).
*
* {@inheritdoc}
*/
public function leaveNode(Node $node)
{
if ($node instanceof Namespace_) {
// Open namespaces (like `namespace Foo;`) have kind == KIND_SEMICOLON.
if ($node->getAttribute('kind') === Namespace_::KIND_SEMICOLON || $node->getAttribute('psyshReinjected')) {
if ($this->cleaner) {
$this->cleaner->setAliasesForNamespace($node->name, $this->aliases);
}
}
$this->aliases = [];
}
return null;
}
/**
* Get a fully-qualified name (class, function, interface, etc).
*
* Resolves use statement aliases before applying namespace.
*
* @param mixed $name
*/
protected function getFullyQualifiedName($name): string
{
if ($name instanceof FullyQualifiedName) {
return \implode('\\', $this->getParts($name));
}
// Check if this name matches a use statement alias
if ($name instanceof Name) {
$nameParts = $this->getParts($name);
$firstPart = \strtolower($nameParts[0]);
if (isset($this->aliases[$firstPart])) {
// Replace first part with the aliased namespace
$aliasedParts = $this->getParts($this->aliases[$firstPart]);
\array_shift($nameParts); // Remove first part
return \implode('\\', \array_merge($aliasedParts, $nameParts));
}
}
if ($name instanceof Name) {
$name = $this->getParts($name);
} elseif (!\is_array($name)) {
$name = [$name];
}
return \implode('\\', \array_merge($this->namespace, $name));
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* At some point we might want to make $namespace a plain string, to match how Name works?
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@ -0,0 +1,119 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Namespace_;
use Psy\CodeCleaner;
/**
* Provide implicit namespaces for subsequent execution.
*
* The namespace pass remembers the last standalone namespace line encountered:
*
* namespace Foo\Bar;
*
* ... which it then applies implicitly to all future evaluated code, until the
* namespace is replaced by another namespace. To reset to the top level
* namespace, enter `namespace {}`. This is a bit ugly, but it does the trick :)
*/
class NamespacePass extends NamespaceAwarePass
{
/**
* @param ?CodeCleaner $cleaner deprecated parameter, use setCleaner() instead
*
* @phpstan-ignore-next-line method.unused
*/
public function __construct(?CodeCleaner $cleaner = null)
{
// No-op, since cleaner is provided by NamespaceAwarePass
}
/**
* If this is a standalone namespace line, remember it for later.
*
* Otherwise, apply remembered namespaces to the code until a new namespace
* is encountered.
*
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
if (empty($nodes)) {
return $nodes;
}
$last = \end($nodes);
if ($last instanceof Namespace_) {
$kind = $last->getAttribute('kind');
if ($kind === Namespace_::KIND_SEMICOLON) {
// Save the current namespace for open namespaces
$this->setNamespace($last->name);
} else {
// Clear the current namespace after a braced namespace
$this->setNamespace(null);
}
return $nodes;
}
// Wrap in current namespace if one is set
$currentNamespace = $this->getCurrentNamespace();
if (!$currentNamespace) {
return $nodes;
}
// Mark as re-injected so UseStatementPass knows it can re-inject use statements
return [new Namespace_($currentNamespace, $nodes, ['psyshReinjected' => true])];
}
/**
* Get the current namespace as a Name node.
*
* This is more complicated than it needs to be, because we're not storing namespace as a Name.
*
* @return Name|null
*/
private function getCurrentNamespace(): ?Name
{
$namespace = $this->cleaner->getNamespace();
return $namespace ? new Name($namespace) : null;
}
/**
* Update the namespace in CodeCleaner and clear aliases.
*
* @param Name|null $namespace
*/
private function setNamespace(?Name $namespace)
{
$this->cleaner->setNamespace($namespace);
// Always clear aliases when changing namespace
$this->cleaner->setAliasesForNamespace($namespace, []);
}
/**
* @deprecated unused and will be removed in a future version
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@ -0,0 +1,33 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
/**
* A class used internally by CodeCleaner to represent input, such as
* non-expression statements, with no return value.
*
* Note that user code returning an instance of this class will act like it
* has no return value, so you prolly shouldn't do that.
*/
class NoReturnValue
{
/**
* Get PhpParser AST expression for creating a new NoReturnValue.
*/
public static function create(): New_
{
return new New_(new FullyQualifiedName(self::class));
}
}

View File

@ -0,0 +1,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that only variables (and variable-like things) are passed by reference.
*/
class PassableByReferencePass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Only variables can be passed by reference';
/**
* @throws FatalErrorException if non-variables are passed by reference
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
// @todo support MethodCall and StaticCall as well.
if ($node instanceof FuncCall) {
// if function name is an expression or a variable, give it a pass for now.
if ($node->name instanceof Expr || $node->name instanceof Variable) {
return null;
}
$name = (string) $node->name;
if ($name === 'array_multisort') {
return $this->validateArrayMultisort($node);
}
try {
$refl = new \ReflectionFunction($name);
} catch (\ReflectionException $e) {
// Well, we gave it a shot!
return null;
}
$args = [];
foreach ($node->args as $position => $arg) {
if ($arg instanceof VariadicPlaceholder) {
continue;
}
// Named arguments were added in php-parser 4.1, so we need to check if the property exists
$key = (\property_exists($arg, 'name') && $arg->name !== null) ? $arg->name->name : $position;
$args[$key] = $arg;
}
foreach ($refl->getParameters() as $key => $param) {
if (\array_key_exists($key, $args) || \array_key_exists($param->name, $args)) {
$arg = $args[$param->name] ?? $args[$key];
if ($param->isPassedByReference() && !$this->isPassableByReference($arg)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
}
return null;
}
private function isPassableByReference(Node $arg): bool
{
if (!\property_exists($arg, 'value')) {
return false;
}
// Unpacked arrays can be passed by reference
if ($arg->value instanceof Array_) {
return \property_exists($arg, 'unpack') && $arg->unpack;
}
// FuncCall, MethodCall and StaticCall are all PHP _warnings_ not fatal errors, so we'll let
// PHP handle those ones :)
return $arg->value instanceof ClassConstFetch ||
$arg->value instanceof PropertyFetch ||
$arg->value instanceof Variable ||
$arg->value instanceof FuncCall ||
$arg->value instanceof MethodCall ||
$arg->value instanceof StaticCall ||
$arg->value instanceof ArrayDimFetch;
}
/**
* Because array_multisort has a problematic signature...
*
* The argument order is all sorts of wonky, and whether something is passed
* by reference or not depends on the values of the two arguments before it.
* We'll do a good faith attempt at validating this, but err on the side of
* permissive.
*
* This is why you don't design languages where core code and extensions can
* implement APIs that wouldn't be possible in userland code.
*
* @throws FatalErrorException for clearly invalid arguments
*
* @param Node $node
*/
private function validateArrayMultisort(Node $node)
{
$nonPassable = 2; // start with 2 because the first one has to be passable by reference
foreach ($node->args as $arg) {
if ($this->isPassableByReference($arg)) {
$nonPassable = 0;
} elseif (++$nonPassable > 2) {
// There can be *at most* two non-passable-by-reference args in a row. This is about
// as close as we can get to validating the arguments for this function :-/
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
}

View File

@ -0,0 +1,138 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Include_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Scalar\LNumber;
use Psy\Exception\ErrorException;
use Psy\Exception\FatalErrorException;
/**
* Add runtime validation for `require` and `require_once` calls.
*/
class RequirePass extends CodeCleanerPass
{
private const REQUIRE_TYPES = [Include_::TYPE_REQUIRE, Include_::TYPE_REQUIRE_ONCE];
/**
* {@inheritdoc}
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $origNode)
{
if (!$this->isRequireNode($origNode)) {
return null;
}
$node = clone $origNode;
/*
* rewrite
*
* $foo = require $bar
*
* to
*
* $foo = require \Psy\CodeCleaner\RequirePass::resolve($bar)
*/
// PHP-Parser 4.x uses LNumber, 5.x has LNumber as an alias to Int_
// Just use LNumber for compatibility with both versions
// @todo Switch to Int_ once we drop support for PHP-Parser 4.x
$arg = new LNumber($origNode->getStartLine());
$node->expr = new StaticCall(
new FullyQualifiedName(self::class),
'resolve',
[new Arg($origNode->expr), new Arg($arg)],
$origNode->getAttributes()
);
return $node;
}
/**
* Runtime validation that $file can be resolved as an include path.
*
* If $file can be resolved, return $file. Otherwise throw a fatal error exception.
*
* If $file collides with a path in the currently running PsySH phar, it will be resolved
* relative to the include path, to prevent PHP from grabbing the phar version of the file.
*
* @throws FatalErrorException when unable to resolve include path for $file
* @throws ErrorException if $file is empty and E_WARNING is included in error_reporting level
*
* @param string $file
* @param int $startLine Line number of the original require expression
*
* @return string Exactly the same as $file, unless $file collides with a path in the currently running phar
*/
public static function resolve($file, $startLine = null): string
{
$file = (string) $file;
if ($file === '') {
// @todo Shell::handleError would be better here, because we could
// fake the file and line number, but we can't call it statically.
// So we're duplicating some of the logics here.
if (\E_WARNING & \error_reporting()) {
ErrorException::throwException(\E_WARNING, 'Filename cannot be empty', null, $startLine);
}
// @todo trigger an error as fallback? this is pretty ugly…
// trigger_error('Filename cannot be empty', E_USER_WARNING);
}
$resolvedPath = \stream_resolve_include_path($file);
if ($file === '' || !$resolvedPath) {
$msg = \sprintf("Failed opening required '%s'", $file);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $startLine);
}
// Special case: if the path is not already relative or absolute, and it would resolve to
// something inside the currently running phar (e.g. `vendor/autoload.php`), we'll resolve
// it relative to the include path so PHP won't grab the phar version.
//
// Note that this only works if the phar has `psysh` in the path. We might want to lift this
// restriction and special case paths that would collide with any running phar?
if ($resolvedPath !== $file && $file[0] !== '.') {
$runningPhar = \Phar::running();
if (\strpos($runningPhar, 'psysh') !== false && \is_file($runningPhar.\DIRECTORY_SEPARATOR.$file)) {
foreach (self::getIncludePath() as $prefix) {
$resolvedPath = $prefix.\DIRECTORY_SEPARATOR.$file;
if (\is_file($resolvedPath)) {
return $resolvedPath;
}
}
}
}
return $file;
}
private function isRequireNode(Node $node): bool
{
return $node instanceof Include_ && \in_array($node->type, self::REQUIRE_TYPES);
}
private static function getIncludePath(): array
{
if (\PATH_SEPARATOR === ':') {
return \preg_split('#:(?!//)#', \get_include_path());
}
return \explode(\PATH_SEPARATOR, \get_include_path());
}
}

View File

@ -0,0 +1,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\UnionType;
use Psy\Exception\FatalErrorException;
/**
* Add runtime validation for return types.
*/
class ReturnTypePass extends CodeCleanerPass
{
const MESSAGE = 'A function with return type must return a value';
const NULLABLE_MESSAGE = 'A function with return type must return a value (did you mean "return null;" instead of "return;"?)';
const VOID_MESSAGE = 'A void function must not return a value';
const VOID_NULL_MESSAGE = 'A void function must not return a value (did you mean "return;" instead of "return null;"?)';
const NULLABLE_VOID_MESSAGE = 'Void type cannot be nullable';
private array $returnTypeStack = [];
/**
* {@inheritdoc}
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($this->isFunctionNode($node)) {
$this->returnTypeStack[] = \property_exists($node, 'returnType') ? $node->returnType : null;
return null;
}
if (!empty($this->returnTypeStack) && $node instanceof Return_) {
$expectedType = \end($this->returnTypeStack);
if ($expectedType === null) {
return null;
}
$msg = null;
if ($this->typeName($expectedType) === 'void') {
// Void functions
if ($expectedType instanceof NullableType) {
$msg = self::NULLABLE_VOID_MESSAGE;
} elseif ($node->expr instanceof ConstFetch && \strtolower($node->expr->name) === 'null') {
$msg = self::VOID_NULL_MESSAGE;
} elseif ($node->expr !== null) {
$msg = self::VOID_MESSAGE;
}
} else {
// Everything else
if ($node->expr === null) {
$msg = $expectedType instanceof NullableType ? self::NULLABLE_MESSAGE : self::MESSAGE;
}
}
if ($msg !== null) {
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
/**
* {@inheritdoc}
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (!empty($this->returnTypeStack) && $this->isFunctionNode($node)) {
\array_pop($this->returnTypeStack);
}
return null;
}
private function isFunctionNode(Node $node): bool
{
return $node instanceof Function_ || $node instanceof Closure;
}
private function typeName(Node $node): string
{
if ($node instanceof UnionType) {
return \implode('|', \array_map([$this, 'typeName'], $node->types));
}
if ($node instanceof IntersectionType) {
return \implode('&', \array_map([$this, 'typeName'], $node->types));
}
if ($node instanceof NullableType) {
return $this->typeName($node->type);
}
if ($node instanceof Identifier || $node instanceof Name) {
return $node->toLowerString();
}
throw new \InvalidArgumentException('Unable to find type name');
}
}

View File

@ -0,0 +1,94 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\DeclareItem;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\Node\Stmt\Declare_;
use PhpParser\Node\Stmt\DeclareDeclare;
use Psy\Exception\FatalErrorException;
/**
* Provide implicit strict types declarations for for subsequent execution.
*
* The strict types pass remembers the last strict types declaration:
*
* declare(strict_types=1);
*
* ... which it then applies implicitly to all future evaluated code, until it
* is replaced by a new declaration.
*/
class StrictTypesPass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'strict_types declaration must have 0 or 1 as its value';
private bool $strictTypes;
/**
* @param bool $strictTypes enforce strict types by default
*/
public function __construct(bool $strictTypes = false)
{
$this->strictTypes = $strictTypes;
}
/**
* If this is a standalone strict types declaration, remember it for later.
*
* Otherwise, apply remembered strict types declaration to to the code until
* a new declaration is encountered.
*
* @throws FatalErrorException if an invalid `strict_types` declaration is found
*
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$prependStrictTypes = $this->strictTypes;
foreach ($nodes as $node) {
if ($node instanceof Declare_) {
foreach ($node->declares as $declare) {
if ($declare->key->toString() === 'strict_types') {
$value = $declare->value;
// @todo Remove LNumber once we drop support for PHP-Parser 4.x
if ((!$value instanceof LNumber && !$value instanceof Int_) || ($value->value !== 0 && $value->value !== 1)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
$this->strictTypes = $value->value === 1;
}
}
}
}
if ($prependStrictTypes) {
$first = \reset($nodes);
if (!$first instanceof Declare_) {
// @todo Switch to PhpParser\Node\DeclareItem once we drop support for PHP-Parser 4.x
// @todo Remove LNumber once we drop support for PHP-Parser 4.x
$arg = \class_exists('PhpParser\Node\Scalar\Int_') ? new Int_(1) : new LNumber(1);
$declareItem = \class_exists('PhpParser\Node\DeclareItem') ?
new DeclareItem('strict_types', $arg) :
new DeclareDeclare('strict_types', $arg);
$declare = new Declare_([$declareItem]);
\array_unshift($nodes, $declare);
}
}
return $nodes;
}
}

View File

@ -0,0 +1,155 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name; // @phan-suppress-current-line PhanUnreferencedUseNormal - used for type checks
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\UseItem;
use PhpParser\Node\Stmt\UseUse;
use Psy\Exception\FatalErrorException;
/**
* Provide implicit use statements for subsequent execution.
*
* The use statement pass remembers the last use statement line encountered:
*
* use Foo\Bar as Baz;
*
* ... which it then applies implicitly to all future evaluated code, until the
* current namespace is replaced by another namespace.
*
* Extends NamespaceAwarePass to leverage shared alias tracking.
*/
class UseStatementPass extends NamespaceAwarePass
{
/**
* {@inheritdoc}
*/
public function enterNode(Node $node)
{
// Check for use statement conflicts BEFORE parent adds it to aliases
// Skip re-injected use statements (marked with 'psyshReinjected' attribute)
if ($node instanceof Use_ && !$node->getAttribute('psyshReinjected')) {
$this->validateUseStatement($node);
}
return parent::enterNode($node);
}
/**
* Re-inject use statements from previous inputs.
*
* Each REPL input is evaluated separately; re-injecting use statements matches PHP behavior for
* namespaces and use statements in a file.
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
parent::beforeTraverse($nodes);
if (!$this->cleaner) {
return null;
}
// Check for namespace declarations in the input
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
// Only re-inject use statements if this is a wrapper created by NamespacePass.
// This matches PHP behavior: explicit namespace declaration clears use statements.
if ($node->getAttribute('psyshReinjected')) {
$aliases = $this->cleaner->getAliasesForNamespace($node->name);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$node->stmts = \array_merge($useStatements, $node->stmts ?? []);
}
}
// Don't process other nodes or return modified nodes
return null;
}
}
// No namespace declaration in input, or re-applied by NamespacePass; re-inject use
// statements for the empty namespace.
$aliases = $this->cleaner->getAliasesForNamespace(null);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$nodes = \array_merge($useStatements, $nodes);
}
return $nodes;
}
/**
* If we have aliases but didn't leave a namespace (global namespace case), persist them to
* CodeCleaner for the next traversal.
*
* {@inheritdoc}
*/
public function afterTraverse(array $nodes)
{
if (!$this->cleaner) {
return null;
}
// Persist aliases if they're at the global level (not inside any namespace)
if (!empty($this->aliases)) {
$this->cleaner->setAliasesForNamespace(null, $this->aliases);
}
return null;
}
/**
* Validate that a use statement doesn't conflict with existing aliases.
*
* @throws FatalErrorException if the alias is already in use
*
* @param Use_ $stmt The use statement node
*/
private function validateUseStatement(Use_ $stmt): void
{
foreach ($stmt->uses as $useItem) {
$alias = \strtolower($useItem->getAlias());
if (isset($this->aliases[$alias])) {
throw new FatalErrorException(\sprintf('Cannot use %s as %s because the name is already in use', $useItem->name->toString(), $useItem->getAlias()), 0, \E_ERROR, null, $stmt->getStartLine());
}
}
}
/**
* Create use statement nodes from stored aliases.
*
* @param array $aliases Map of lowercase alias names to Name nodes
*
* @return Use_[] Array of use statement nodes
*/
private function createUseStatements(array $aliases): array
{
$useStatements = [];
foreach ($aliases as $alias => $name) {
// Create UseItem (PHP-Parser 5.x) or UseUse (PHP-Parser 4.x)
$useItem = \class_exists(UseItem::class)
? new UseItem($name, new Identifier($alias))
: new UseUse($name, $alias);
// Mark as re-injected so we don't validate it
$useStatements[] = new Use_([$useItem], Use_::TYPE_NORMAL, ['psyshReinjected' => true]);
}
return $useStatements;
}
}

View File

@ -0,0 +1,332 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Ternary;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Interface_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* Validate that classes exist.
*
* This pass throws a FatalErrorException rather than letting PHP run
* headfirst into a real fatal error and die.
*/
class ValidClassNamePass extends NamespaceAwarePass
{
const CLASS_TYPE = 'class';
const INTERFACE_TYPE = 'interface';
const TRAIT_TYPE = 'trait';
private int $conditionalScopes = 0;
/**
* Validate class, interface and trait definitions.
*
* Validate them upon entering the node, so that we know about their
* presence and can validate constant fetches and static calls in class or
* trait methods.
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
parent::enterNode($node);
if (self::isConditional($node)) {
$this->conditionalScopes++;
return null;
}
if ($this->conditionalScopes === 0) {
if ($node instanceof Class_) {
$this->validateClassStatement($node);
} elseif ($node instanceof Interface_) {
$this->validateInterfaceStatement($node);
} elseif ($node instanceof Trait_) {
$this->validateTraitStatement($node);
}
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (self::isConditional($node)) {
$this->conditionalScopes--;
}
return null;
}
private static function isConditional(Node $node): bool
{
return $node instanceof If_ ||
$node instanceof While_ ||
$node instanceof Do_ ||
$node instanceof Switch_ ||
$node instanceof Ternary;
}
/**
* Validate a class definition statement.
*
* @param Class_ $stmt
*/
protected function validateClassStatement(Class_ $stmt)
{
$this->ensureCanDefine($stmt, self::CLASS_TYPE);
if (isset($stmt->extends)) {
$this->ensureClassExists($this->getFullyQualifiedName($stmt->extends), $stmt);
}
$this->ensureInterfacesExist($stmt->implements, $stmt);
}
/**
* Validate an interface definition statement.
*
* @param Interface_ $stmt
*/
protected function validateInterfaceStatement(Interface_ $stmt)
{
$this->ensureCanDefine($stmt, self::INTERFACE_TYPE);
$this->ensureInterfacesExist($stmt->extends, $stmt);
}
/**
* Validate a trait definition statement.
*
* @param Trait_ $stmt
*/
protected function validateTraitStatement(Trait_ $stmt)
{
$this->ensureCanDefine($stmt, self::TRAIT_TYPE);
}
/**
* Ensure that no class, interface or trait name collides with a new definition.
*
* @throws FatalErrorException
*
* @param Stmt $stmt
* @param string $scopeType
*/
protected function ensureCanDefine(Stmt $stmt, string $scopeType = self::CLASS_TYPE)
{
// Anonymous classes don't have a name, and uniqueness shouldn't be enforced.
if (!\property_exists($stmt, 'name') || $stmt->name === null) {
return;
}
$name = $this->getFullyQualifiedName($stmt->name);
// check for name collisions
$errorType = null;
if ($this->classExists($name)) {
$errorType = self::CLASS_TYPE;
} elseif ($this->interfaceExists($name)) {
$errorType = self::INTERFACE_TYPE;
} elseif ($this->traitExists($name)) {
$errorType = self::TRAIT_TYPE;
}
if ($errorType !== null) {
throw $this->createError(\sprintf('%s named %s already exists', \ucfirst($errorType), $name), $stmt);
}
// Store creation for the rest of this code snippet so we can find local
// issue too
$this->currentScope[\strtolower($name)] = $scopeType;
}
/**
* Ensure that a referenced class exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a referenced class _or interface_ exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassOrInterfaceExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name) && !$this->interfaceExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a referenced class _or trait_ exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassOrTraitExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name) && !$this->traitExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a statically called method exists.
*
* @throws FatalErrorException
*
* @param string $class
* @param string $name
* @param Stmt $stmt
*/
protected function ensureMethodExists(string $class, string $name, Stmt $stmt)
{
$this->ensureClassOrTraitExists($class, $stmt);
// let's pretend all calls to self, parent and static are valid
if (\in_array(\strtolower($class), ['self', 'parent', 'static'])) {
return;
}
// ... and all calls to classes defined right now
if ($this->findInScope($class) === self::CLASS_TYPE) {
return;
}
// if method name is an expression, give it a pass for now
if ($name instanceof Expr) {
return;
}
if (!\method_exists($class, $name) && !\method_exists($class, '__callStatic')) {
throw $this->createError(\sprintf('Call to undefined method %s::%s()', $class, $name), $stmt);
}
}
/**
* Ensure that a referenced interface exists.
*
* @throws FatalErrorException
*
* @param Interface_[] $interfaces
* @param Stmt $stmt
*/
protected function ensureInterfacesExist(array $interfaces, Stmt $stmt)
{
foreach ($interfaces as $interface) {
/** @var string $name */
$name = $this->getFullyQualifiedName($interface);
if (!$this->interfaceExists($name)) {
throw $this->createError(\sprintf('Interface \'%s\' not found', $name), $stmt);
}
}
}
/**
* Check whether a class exists, or has been defined in the current code snippet.
*
* Gives `self`, `static` and `parent` a free pass.
*
* @param string $name
*/
protected function classExists(string $name): bool
{
// Give `self`, `static` and `parent` a pass. This will actually let
// some errors through, since we're not checking whether the keyword is
// being used in a class scope.
if (\in_array(\strtolower($name), ['self', 'static', 'parent'])) {
return true;
}
return \class_exists($name) || $this->findInScope($name) === self::CLASS_TYPE;
}
/**
* Check whether an interface exists, or has been defined in the current code snippet.
*
* @param string $name
*/
protected function interfaceExists(string $name): bool
{
return \interface_exists($name) || $this->findInScope($name) === self::INTERFACE_TYPE;
}
/**
* Check whether a trait exists, or has been defined in the current code snippet.
*
* @param string $name
*/
protected function traitExists(string $name): bool
{
return \trait_exists($name) || $this->findInScope($name) === self::TRAIT_TYPE;
}
/**
* Find a symbol in the current code snippet scope.
*
* @param string $name
*
* @return string|null
*/
protected function findInScope(string $name)
{
$name = \strtolower($name);
if (isset($this->currentScope[$name])) {
return $this->currentScope[$name];
}
return null;
}
/**
* Error creation factory.
*
* @param string $msg
* @param Stmt $stmt
*/
protected function createError(string $msg, Stmt $stmt): FatalErrorException
{
return new FatalErrorException($msg, 0, \E_ERROR, null, $stmt->getStartLine());
}
}

View File

@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;
use Psy\Exception\FatalErrorException;
/**
* Validate that the constructor method is not static, and does not have a
* return type.
*
* Checks both explicit __construct methods as well as old-style constructor
* methods with the same name as the class (for non-namespaced classes).
*
* As of PHP 5.3.3, methods with the same name as the last element of a
* namespaced class name will no longer be treated as constructor. This change
* doesn't affect non-namespaced classes.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class ValidConstructorPass extends CodeCleanerPass
{
private array $namespace = [];
/**
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->namespace = [];
return null;
}
/**
* Validate that the constructor is not static and does not have a return type.
*
* @throws FatalErrorException the constructor function is static
* @throws FatalErrorException the constructor function has a return type
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Namespace_) {
$this->namespace = isset($node->name) ? $this->getParts($node->name) : [];
} elseif ($node instanceof Class_) {
$constructor = null;
foreach ($node->stmts as $stmt) {
if ($stmt instanceof ClassMethod) {
// If we find a new-style constructor, no need to look for the old-style
if (\property_exists($stmt, 'name') && \strtolower($stmt->name) === '__construct') {
$this->validateConstructor($stmt, $node);
return null;
}
// We found a possible old-style constructor (unless there is also a __construct method)
if (empty($this->namespace) && $node->name !== null && \property_exists($stmt, 'name') && \strtolower($node->name) === \strtolower($stmt->name)) {
$constructor = $stmt;
}
}
}
if ($constructor) {
$this->validateConstructor($constructor, $node);
}
}
return null;
}
/**
* @throws FatalErrorException the constructor function is static
* @throws FatalErrorException the constructor function has a return type
*
* @param Node $constructor
* @param Node $classNode
*/
private function validateConstructor(Node $constructor, Node $classNode)
{
if (\method_exists($constructor, 'isStatic') && $constructor->isStatic()) {
$msg = \sprintf(
'Constructor %s::%s() cannot be static',
\implode('\\', \array_merge($this->namespace, (array) $classNode->name->toString())),
\property_exists($constructor, 'name') ? $constructor->name : '__construct'
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $classNode->getStartLine());
}
if (\method_exists($constructor, 'getReturnType') && $constructor->getReturnType()) {
$msg = \sprintf(
'Constructor %s::%s() cannot declare a return type',
\implode('\\', \array_merge($this->namespace, (array) $classNode->name->toString())),
\property_exists($constructor, 'name') ? $constructor->name : '__construct'
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $classNode->getStartLine());
}
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* At some point we might want to make $namespace a plain string, to match how Name works?
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* Validate that function calls will succeed.
*
* This pass throws a FatalErrorException rather than letting PHP run
* headfirst into a real fatal error and die.
*/
class ValidFunctionNamePass extends NamespaceAwarePass
{
private int $conditionalScopes = 0;
/**
* Store newly defined function names on the way in, to allow recursion.
*
* @throws FatalErrorException if a function is redefined in a non-conditional scope
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
parent::enterNode($node);
if (self::isConditional($node)) {
$this->conditionalScopes++;
} elseif ($node instanceof Function_) {
$name = $this->getFullyQualifiedName($node->name);
// @todo add an "else" here which adds a runtime check for instances where we can't tell
// whether a function is being redefined by static analysis alone.
if ($this->conditionalScopes === 0) {
if (\function_exists($name) ||
isset($this->currentScope[\strtolower($name)])) {
$msg = \sprintf('Cannot redeclare %s()', $name);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
$this->currentScope[\strtolower($name)] = true;
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (self::isConditional($node)) {
$this->conditionalScopes--;
}
return null;
}
private static function isConditional(Node $node)
{
return $node instanceof If_ ||
$node instanceof While_ ||
$node instanceof Do_ ||
$node instanceof Switch_;
}
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* CodeCleanerAware interface.
*
* This interface is used to pass the Shell's CodeCleaner into commands which
* require access to name resolution via use statements and namespace context.
*/
interface CodeCleanerAware
{
/**
* Set the CodeCleaner instance.
*/
public function setCodeCleaner(CodeCleaner $cleaner);
}

View File

@ -0,0 +1,83 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Output\ShellOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Interact with the current code buffer.
*
* Shows and clears the buffer for the current multi-line expression.
*/
class BufferCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('buffer')
->setAliases(['buf'])
->setDefinition([
new InputOption('clear', '', InputOption::VALUE_NONE, 'Clear the current buffer.'),
])
->setDescription('Show (or clear) the contents of the code input buffer.')
->setHelp(
<<<'HELP'
Show the contents of the code buffer for the current multi-line expression.
Optionally, clear the buffer by passing the <info>--clear</info> option.
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shell = $this->getShell();
$buf = $shell->getCodeBuffer();
if ($input->getOption('clear')) {
$shell->resetCodeBuffer();
$output->writeln($this->formatLines($buf, 'urgent'), ShellOutput::NUMBER_LINES);
} else {
$output->writeln($this->formatLines($buf), ShellOutput::NUMBER_LINES);
}
return 0;
}
/**
* A helper method for wrapping buffer lines in `<urgent>` and `<return>` formatter strings.
*
* @param array $lines
* @param string $type (default: 'return')
*
* @return array Formatted strings
*/
protected function formatLines(array $lines, string $type = 'return'): array
{
$template = \sprintf('<%s>%%s</%s>', $type, $type);
return \array_map(function ($line) use ($template) {
return \sprintf($template, $line);
}, $lines);
}
}

View File

@ -0,0 +1,53 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Clear the Psy Shell.
*
* Just what it says on the tin.
*/
class ClearCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('clear')
->setDefinition([])
->setDescription('Clear the Psy Shell screen.')
->setHelp(
<<<'HELP'
Clear the Psy Shell screen.
Pro Tip: If your PHP has readline support, you should be able to use ctrl+l too!
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->write(\sprintf('%c[2J%c[0;0f', 27, 27));
return 0;
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\Parser;
use Psy\Exception\ParseErrorException;
use Psy\ParserFactory;
/**
* Class CodeArgumentParser.
*/
class CodeArgumentParser
{
private Parser $parser;
public function __construct(?Parser $parser = null)
{
$this->parser = $parser ?? (new ParserFactory())->createParser();
}
/**
* Lex and parse a string of code into statements.
*
* This is intended for code arguments, so the code string *should not* start with <?php
*
* @throws ParseErrorException
*
* @return array Statements
*/
public function parse(string $code): array
{
$code = '<?php '.$code;
try {
return $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
if (\strpos($e->getMessage(), 'unexpected EOF') === false) {
throw ParseErrorException::fromParseError($e);
}
// If we got an unexpected EOF, let's try it again with a semicolon.
try {
return $this->parser->parse($code.';');
} catch (\PhpParser\Error $_e) {
// Throw the original error, not the semicolon one.
throw ParseErrorException::fromParseError($e);
}
}
}
}

274
vendor/psy/psysh/src/Command/Command.php vendored Normal file
View File

@ -0,0 +1,274 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Shell;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command as BaseCommand;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableStyle;
use Symfony\Component\Console\Output\OutputInterface;
/**
* The Psy Shell base command.
*/
abstract class Command extends BaseCommand
{
/**
* Sets the application instance for this command.
*
* @param Application|null $application An Application instance
*
* @api
*/
public function setApplication(?Application $application = null): void
{
if ($application !== null && !$application instanceof Shell) {
throw new \InvalidArgumentException('PsySH Commands require an instance of Psy\Shell');
}
parent::setApplication($application);
}
/**
* getApplication, but is guaranteed to return a Shell instance.
*/
protected function getShell(): Shell
{
$shell = $this->getApplication();
if (!$shell instanceof Shell) {
throw new \RuntimeException('PsySH Commands require an instance of Psy\Shell');
}
return $shell;
}
/**
* {@inheritdoc}
*/
public function asText(): string
{
$messages = [
'<comment>Usage:</comment>',
' '.$this->getSynopsis(),
'',
];
if ($this->getAliases()) {
$messages[] = $this->aliasesAsText();
}
if ($this->getArguments()) {
$messages[] = $this->argumentsAsText();
}
if ($this->getOptions()) {
$messages[] = $this->optionsAsText();
}
if ($help = $this->getProcessedHelp()) {
$messages[] = '<comment>Help:</comment>';
$messages[] = ' '.\str_replace("\n", "\n ", $help)."\n";
}
return \implode("\n", $messages);
}
/**
* {@inheritdoc}
*/
private function getArguments(): array
{
$hidden = $this->getHiddenArguments();
return \array_filter($this->getNativeDefinition()->getArguments(), function ($argument) use ($hidden) {
return !\in_array($argument->getName(), $hidden);
});
}
/**
* These arguments will be excluded from help output.
*
* @return string[]
*/
protected function getHiddenArguments(): array
{
return ['command'];
}
/**
* {@inheritdoc}
*/
private function getOptions(): array
{
$hidden = $this->getHiddenOptions();
return \array_filter($this->getNativeDefinition()->getOptions(), function ($option) use ($hidden) {
return !\in_array($option->getName(), $hidden);
});
}
/**
* These options will be excluded from help output.
*
* @return string[]
*/
protected function getHiddenOptions(): array
{
return ['verbose'];
}
/**
* Format command aliases as text..
*/
private function aliasesAsText(): string
{
return '<comment>Aliases:</comment> <info>'.\implode(', ', $this->getAliases()).'</info>'.\PHP_EOL;
}
/**
* Format command arguments as text.
*/
private function argumentsAsText(): string
{
$max = $this->getMaxWidth();
$messages = [];
$arguments = $this->getArguments();
if (!empty($arguments)) {
$messages[] = '<comment>Arguments:</comment>';
foreach ($arguments as $argument) {
if (null !== $argument->getDefault() && (!\is_array($argument->getDefault()) || \count($argument->getDefault()))) {
$default = \sprintf('<comment> (default: %s)</comment>', $this->formatDefaultValue($argument->getDefault()));
} else {
$default = '';
}
$name = $argument->getName();
// @phan-suppress-next-line PhanParamSuspiciousOrder - intentionally padding empty string to create spaces
$pad = \str_pad('', $max - \strlen($name));
// @phan-suppress-next-line PhanParamSuspiciousOrder - intentionally padding empty string to create spaces
$description = \str_replace("\n", "\n".\str_pad('', $max + 2, ' '), $argument->getDescription());
$messages[] = \sprintf(' <info>%s</info>%s %s%s', $name, $pad, $description, $default);
}
$messages[] = '';
}
return \implode(\PHP_EOL, $messages);
}
/**
* Format options as text.
*/
private function optionsAsText(): string
{
$max = $this->getMaxWidth();
$messages = [];
$options = $this->getOptions();
if ($options) {
$messages[] = '<comment>Options:</comment>';
foreach ($options as $option) {
if ($option->acceptValue() && null !== $option->getDefault() && (!\is_array($option->getDefault()) || \count($option->getDefault()))) {
$default = \sprintf('<comment> (default: %s)</comment>', $this->formatDefaultValue($option->getDefault()));
} else {
$default = '';
}
$multiple = $option->isArray() ? '<comment> (multiple values allowed)</comment>' : '';
// @phan-suppress-next-line PhanParamSuspiciousOrder - intentionally padding empty string to create spaces
$description = \str_replace("\n", "\n".\str_pad('', $max + 2, ' '), $option->getDescription());
$optionMax = $max - \strlen($option->getName()) - 2;
$messages[] = \sprintf(
" <info>%s</info> %-{$optionMax}s%s%s%s",
'--'.$option->getName(),
$option->getShortcut() ? \sprintf('(-%s) ', $option->getShortcut()) : '',
$description,
$default,
$multiple
);
}
$messages[] = '';
}
return \implode(\PHP_EOL, $messages);
}
/**
* Calculate the maximum padding width for a set of lines.
*/
private function getMaxWidth(): int
{
$max = 0;
foreach ($this->getOptions() as $option) {
$nameLength = \strlen($option->getName()) + 2;
if ($option->getShortcut()) {
$nameLength += \strlen($option->getShortcut()) + 3;
}
$max = \max($max, $nameLength);
}
foreach ($this->getArguments() as $argument) {
$max = \max($max, \strlen($argument->getName()));
}
return ++$max;
}
/**
* Format an option default as text.
*
* @param mixed $default
*/
private function formatDefaultValue($default): string
{
if (\is_array($default) && $default === \array_values($default)) {
return \sprintf("['%s']", \implode("', '", $default));
}
return \str_replace("\n", '', \var_export($default, true));
}
/**
* Get a Table instance.
*
* @return Table
*/
protected function getTable(OutputInterface $output)
{
$style = new TableStyle();
// Symfony 4.1 deprecated single-argument style setters.
if (\method_exists($style, 'setVerticalBorderChars')) {
$style->setVerticalBorderChars(' ');
$style->setHorizontalBorderChars('');
$style->setCrossingChars('', '', '', '', '', '', '', '', '');
} else {
$style->setVerticalBorderChar(' ');
$style->setHorizontalBorderChar('');
$style->setCrossingChar('');
}
$table = new Table($output);
return $table
->setRows([])
->setStyle($style);
}
}

View File

@ -0,0 +1,369 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Configuration;
use Psy\Formatter\DocblockFormatter;
use Psy\Formatter\ManualFormatter;
use Psy\Formatter\SignatureFormatter;
use Psy\Input\CodeArgument;
use Psy\ManualUpdater\ManualUpdate;
use Psy\Output\ShellOutput;
use Psy\Reflection\ReflectionConstant;
use Psy\Reflection\ReflectionLanguageConstruct;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Read the documentation for an object, class, constant, method or property.
*/
class DocCommand extends ReflectingCommand
{
const INHERIT_DOC_TAG = '{@inheritdoc}';
private ?Configuration $config = null;
/**
* Set the configuration instance.
*
* @param \Psy\Configuration $config
*/
public function setConfiguration(Configuration $config)
{
$this->config = $config;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('doc')
->setAliases(['rtfm', 'man'])
->setDefinition([
new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show documentation for superclasses as well as the current class.'),
new InputOption('update-manual', null, InputOption::VALUE_OPTIONAL, 'Download and install the latest PHP manual (optional language code)', false),
new CodeArgument('target', CodeArgument::OPTIONAL, 'Function, class, instance, constant, method or property to document.'),
])
->setDescription('Read the documentation for an object, class, constant, method or property.')
->setHelp(
<<<HELP
Read the documentation for an object, class, constant, method or property.
It's awesome for well-documented code, not quite as awesome for poorly documented code.
e.g.
<return>>>> doc preg_replace</return>
<return>>>> doc Psy\Shell</return>
<return>>>> doc Psy\Shell::debug</return>
<return>>>> \$s = new Psy\Shell</return>
<return>>>> doc \$s->run</return>
<return>>>> doc --update-manual</return>
<return>>>> doc --update-manual=fr</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($input->getOption('update-manual') !== false) {
return $this->handleUpdateManual($input, $output);
}
$value = $input->getArgument('target');
if (!$value) {
throw new RuntimeException('Not enough arguments (missing: "target").');
}
if (ReflectionLanguageConstruct::isLanguageConstruct($value)) {
$reflector = new ReflectionLanguageConstruct($value);
$doc = $this->getManualDocById($value);
} else {
list($target, $reflector) = $this->getTargetAndReflector($value, $output);
$doc = $this->getManualDoc($reflector) ?: DocblockFormatter::format($reflector);
}
$hasManual = $this->getShell()->getManual() !== null;
if ($output instanceof ShellOutput) {
$output->startPaging();
}
// Maybe include the declaring class
if ($reflector instanceof \ReflectionMethod || $reflector instanceof \ReflectionProperty) {
$output->writeln(SignatureFormatter::format($reflector->getDeclaringClass()));
}
$output->writeln(SignatureFormatter::format($reflector));
$output->writeln('');
if (empty($doc) && !$hasManual) {
$output->writeln('<warning>PHP manual not found</warning>');
$output->writeln(' To document core PHP functionality, download the PHP reference manual:');
$output->writeln(' https://github.com/bobthecow/psysh/wiki/PHP-manual');
} else {
$output->writeln($doc);
}
// Implicit --all if the original docblock has an {@inheritdoc} tag.
if ($input->getOption('all') || ($doc && \stripos($doc, self::INHERIT_DOC_TAG) !== false)) {
$parent = $reflector;
foreach ($this->getParentReflectors($reflector) as $parent) {
$output->writeln('');
$output->writeln('---');
$output->writeln('');
// Maybe include the declaring class
if ($parent instanceof \ReflectionMethod || $parent instanceof \ReflectionProperty) {
$output->writeln(SignatureFormatter::format($parent->getDeclaringClass()));
}
$output->writeln(SignatureFormatter::format($parent));
$output->writeln('');
if ($doc = $this->getManualDoc($parent) ?: DocblockFormatter::format($parent)) {
$output->writeln($doc);
}
}
}
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
// Set some magic local variables
$this->setCommandScopeVariables($reflector);
return 0;
}
/**
* Handle the manual update operation.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int 0 if everything went fine, or an exit code
*/
private function handleUpdateManual(InputInterface $input, OutputInterface $output): int
{
if (!$this->config) {
$output->writeln('<error>Configuration not available for manual updates.</error>');
return 1;
}
// Create a synthetic input with the update-manual option
$definition = new InputDefinition([
new InputOption('update-manual', null, InputOption::VALUE_OPTIONAL, '', false),
]);
// Get the language value: if true (no value), use null to preserve current language
$lang = $input->getOption('update-manual');
$updateValue = ($lang === true) ? null : $lang;
$updateInput = new ArrayInput(['--update-manual' => $updateValue], $definition);
$updateInput->setInteractive($input->isInteractive());
try {
$manualUpdate = ManualUpdate::fromConfig($this->config, $updateInput, $output);
$result = $manualUpdate->run($updateInput, $output);
if ($result === 0) {
$output->writeln('');
$output->writeln('Restart PsySH to use the updated manual.');
}
return $result;
} catch (\RuntimeException $e) {
$output->writeln(\sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
}
private function getManualDoc($reflector)
{
switch (\get_class($reflector)) {
case \ReflectionClass::class:
case \ReflectionObject::class:
case \ReflectionFunction::class:
$id = $reflector->name;
break;
case \ReflectionMethod::class:
$id = $reflector->class.'::'.$reflector->name;
break;
case \ReflectionProperty::class:
$id = $reflector->class.'::$'.$reflector->name;
break;
case \ReflectionClassConstant::class:
// @todo this is going to collide with ReflectionMethod ids
// someday... start running the query by id + type if the DB
// supports it.
$id = $reflector->class.'::'.$reflector->name;
break;
case ReflectionConstant::class:
$id = $reflector->name;
break;
default:
return false;
}
return $this->getManualDocById($id);
}
/**
* Get all all parent Reflectors for a given Reflector.
*
* For example, passing a Class, Object or TraitReflector will yield all
* traits and parent classes. Passing a Method or PropertyReflector will
* yield Reflectors for the same-named method or property on all traits and
* parent classes.
*
* @return \Generator a whole bunch of \Reflector instances
*/
private function getParentReflectors($reflector): \Generator
{
$seenClasses = [];
switch (\get_class($reflector)) {
case \ReflectionClass::class:
case \ReflectionObject::class:
foreach ($reflector->getTraits() as $trait) {
if (!\in_array($trait->getName(), $seenClasses)) {
$seenClasses[] = $trait->getName();
yield $trait;
}
}
foreach ($reflector->getInterfaces() as $interface) {
if (!\in_array($interface->getName(), $seenClasses)) {
$seenClasses[] = $interface->getName();
yield $interface;
}
}
while ($reflector = $reflector->getParentClass()) {
yield $reflector;
foreach ($reflector->getTraits() as $trait) {
if (!\in_array($trait->getName(), $seenClasses)) {
$seenClasses[] = $trait->getName();
yield $trait;
}
}
foreach ($reflector->getInterfaces() as $interface) {
if (!\in_array($interface->getName(), $seenClasses)) {
$seenClasses[] = $interface->getName();
yield $interface;
}
}
}
return;
case \ReflectionMethod::class:
foreach ($this->getParentReflectors($reflector->getDeclaringClass()) as $parent) {
if ($parent->hasMethod($reflector->getName())) {
$parentMethod = $parent->getMethod($reflector->getName());
if (!\in_array($parentMethod->getDeclaringClass()->getName(), $seenClasses)) {
$seenClasses[] = $parentMethod->getDeclaringClass()->getName();
yield $parentMethod;
}
}
}
return;
case \ReflectionProperty::class:
foreach ($this->getParentReflectors($reflector->getDeclaringClass()) as $parent) {
if ($parent->hasProperty($reflector->getName())) {
$parentProperty = $parent->getProperty($reflector->getName());
if (!\in_array($parentProperty->getDeclaringClass()->getName(), $seenClasses)) {
$seenClasses[] = $parentProperty->getDeclaringClass()->getName();
yield $parentProperty;
}
}
}
break;
}
}
private function getManualDocById($id)
{
if ($manual = $this->getShell()->getManual()) {
switch ($manual->getVersion()) {
case 2:
// v2 manual docs are pre-formatted and should be rendered as-is
return $manual->get($id);
case 3:
if ($doc = $manual->get($id)) {
$width = $this->getTerminalWidth();
$formatter = new ManualFormatter($width, $manual);
return $formatter->format($doc);
}
break;
}
}
return null;
}
/**
* Get the current terminal width for text wrapping.
*
* @return int Terminal width in columns
*/
private function getTerminalWidth(): int
{
// Query terminal size directly
if (\function_exists('shell_exec')) {
// Output format: "rows cols"
$output = @\shell_exec('stty size </dev/tty 2>/dev/null');
if ($output && \preg_match('/^\d+ (\d+)$/', \trim($output), $matches)) {
return (int) $matches[1];
}
$width = @\shell_exec('tput cols </dev/tty 2>/dev/null');
if ($width && \is_numeric(\trim($width))) {
return (int) \trim($width);
}
}
// Check COLUMNS environment variable (may be stale after resize)
$width = \getenv('COLUMNS');
if ($width && \is_numeric(\trim($width))) {
return (int) \trim($width);
}
// Fallback to 100 if we can't detect
return 100;
}
}

View File

@ -0,0 +1,90 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Exception\RuntimeException;
use Psy\Input\CodeArgument;
use Psy\Output\ShellOutput;
use Psy\VarDumper\Presenter;
use Psy\VarDumper\PresenterAware;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Dump an object or primitive.
*
* This is like var_dump but *way* awesomer.
*/
class DumpCommand extends ReflectingCommand implements PresenterAware
{
private Presenter $presenter;
/**
* PresenterAware interface.
*
* @param Presenter $presenter
*/
public function setPresenter(Presenter $presenter)
{
$this->presenter = $presenter;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('dump')
->setDefinition([
new CodeArgument('target', CodeArgument::REQUIRED, 'A target object or primitive to dump.'),
new InputOption('depth', '', InputOption::VALUE_REQUIRED, 'Depth to parse.', 10),
new InputOption('all', 'a', InputOption::VALUE_NONE, 'Include private and protected methods and properties.'),
])
->setDescription('Dump an object or primitive.')
->setHelp(
<<<'HELP'
Dump an object or primitive.
This is like var_dump but <strong>way</strong> awesomer.
e.g.
<return>>>> dump $_</return>
<return>>>> dump $someVar</return>
<return>>>> dump $stuff->getAll()</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$output instanceof ShellOutput) {
throw new RuntimeException('DumpCommand requires a ShellOutput');
}
$depth = $input->getOption('depth');
$target = $this->resolveCode($input->getArgument('target'));
$output->page($this->presenter->present($target, $depth, $input->getOption('all') ? Presenter::VERBOSE : 0));
if (\is_object($target)) {
$this->setCommandScopeVariables(new \ReflectionObject($target));
}
return 0;
}
}

View File

@ -0,0 +1,183 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\ConfigPaths;
use Psy\Context;
use Psy\ContextAware;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class EditCommand extends Command implements ContextAware
{
private string $runtimeDir = '';
private Context $context;
/**
* Constructor.
*
* @param string $runtimeDir The directory to use for temporary files
* @param string|null $name The name of the command; passing null means it must be set in configure()
*
* @throws \Symfony\Component\Console\Exception\LogicException When the command name is empty
*/
public function __construct($runtimeDir, $name = null)
{
parent::__construct($name);
$this->runtimeDir = $runtimeDir;
}
protected function configure(): void
{
$this
->setName('edit')
->setDefinition([
new InputArgument('file', InputArgument::OPTIONAL, 'The file to open for editing. If this is not given, edits a temporary file.', null),
new InputOption(
'exec',
'e',
InputOption::VALUE_NONE,
'Execute the file content after editing. This is the default when a file name argument is not given.',
null
),
new InputOption(
'no-exec',
'E',
InputOption::VALUE_NONE,
'Do not execute the file content after editing. This is the default when a file name argument is given.',
null
),
])
->setDescription('Open an external editor. Afterwards, get produced code in input buffer.')
->setHelp('Set the EDITOR environment variable to something you\'d like to use.');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int 0 if everything went fine, or an exit code
*
* @throws \InvalidArgumentException when both exec and no-exec flags are given or if a given variable is not found in the current context
* @throws \UnexpectedValueException if file_get_contents on the edited file returns false instead of a string
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($input->getOption('exec') &&
$input->getOption('no-exec')) {
throw new \InvalidArgumentException('The --exec and --no-exec flags are mutually exclusive');
}
$filePath = $this->extractFilePath($input->getArgument('file'));
$execute = $this->shouldExecuteFile(
$input->getOption('exec'),
$input->getOption('no-exec'),
$filePath
);
$shouldRemoveFile = false;
if ($filePath === null) {
ConfigPaths::ensureDir($this->runtimeDir);
$filePath = \tempnam($this->runtimeDir, 'psysh-edit-command');
$shouldRemoveFile = true;
}
$editedContent = $this->editFile($filePath, $shouldRemoveFile);
if ($execute) {
$this->getShell()->addInput($editedContent);
}
return 0;
}
/**
* @param bool $execOption
* @param bool $noExecOption
* @param string|null $filePath
*/
private function shouldExecuteFile(bool $execOption, bool $noExecOption, ?string $filePath = null): bool
{
if ($execOption) {
return true;
}
if ($noExecOption) {
return false;
}
// By default, code that is edited is executed if there was no given input file path
return $filePath === null;
}
/**
* @param string|null $fileArgument
*
* @return string|null The file path to edit, null if the input was null, or the value of the referenced variable
*
* @throws \InvalidArgumentException If the variable is not found in the current context
*/
private function extractFilePath(?string $fileArgument = null)
{
// If the file argument was a variable, get it from the context
if ($fileArgument !== null &&
$fileArgument !== '' &&
$fileArgument[0] === '$') {
$fileArgument = $this->context->get(\preg_replace('/^\$/', '', $fileArgument));
}
return $fileArgument;
}
/**
* @param string $filePath
* @param bool $shouldRemoveFile
*
* @throws \UnexpectedValueException if file_get_contents on $filePath returns false instead of a string
*/
private function editFile(string $filePath, bool $shouldRemoveFile): string
{
$escapedFilePath = \escapeshellarg($filePath);
$editor = (isset($_SERVER['EDITOR']) && $_SERVER['EDITOR']) ? $_SERVER['EDITOR'] : 'nano';
$pipes = [];
$proc = \proc_open("{$editor} {$escapedFilePath}", [\STDIN, \STDOUT, \STDERR], $pipes);
\proc_close($proc);
$editedContent = @\file_get_contents($filePath);
if ($shouldRemoveFile) {
@\unlink($filePath);
}
if ($editedContent === false) {
throw new \UnexpectedValueException("Reading {$filePath} returned false");
}
return $editedContent;
}
/**
* Set the Context reference.
*
* @param Context $context
*/
public function setContext(Context $context)
{
$this->context = $context;
}
}

View File

@ -0,0 +1,54 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Exception\BreakException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Exit the Psy Shell.
*
* Just what it says on the tin.
*/
class ExitCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('exit')
->setAliases(['quit', 'q'])
->setDefinition([])
->setDescription('End the current session and return to caller.')
->setHelp(
<<<'HELP'
End the current session and return to caller.
e.g.
<return>>>> exit</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
throw new BreakException('Goodbye');
}
}

View File

@ -0,0 +1,119 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Output\ShellOutput;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Help command.
*
* Lists available commands, and gives command-specific help when asked nicely.
*/
class HelpCommand extends Command
{
private ?Command $command = null;
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('help')
->setAliases(['?'])
->setDefinition([
new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name.', null),
])
->setDescription('Show a list of commands. Type `help [foo]` for information about [foo].')
->setHelp('My. How meta.');
}
/**
* Helper for setting a subcommand to retrieve help for.
*
* @param Command $command
*/
public function setCommand(Command $command)
{
$this->command = $command;
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($this->command !== null) {
// help for an individual command
$output->page($this->command->asText());
$this->command = null;
} elseif ($name = $input->getArgument('command_name')) {
// help for an individual command
try {
$cmd = $this->getApplication()->get($name);
} catch (CommandNotFoundException $e) {
$this->getShell()->writeException($e);
$output->writeln('');
$output->writeln(\sprintf(
'<aside>To read PHP documentation, use <return>doc %s</return></aside>',
$name
));
$output->writeln('');
return 1;
}
$output->page($cmd->asText());
} else {
// list available commands
$commands = $this->getApplication()->all();
$table = $this->getTable($output);
foreach ($commands as $name => $command) {
if ($name !== $command->getName()) {
continue;
}
if ($command->getAliases()) {
$aliases = \sprintf('<comment>Aliases:</comment> %s', \implode(', ', $command->getAliases()));
} else {
$aliases = '';
}
$table->addRow([
\sprintf('<info>%s</info>', $name),
$command->getDescription(),
$aliases,
]);
}
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$table->render();
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
}
return 0;
}
}

View File

@ -0,0 +1,269 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\ConfigPaths;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\Readline\Readline;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Psy Shell history command.
*
* Shows, searches and replays readline history. Not too shabby.
*/
class HistoryCommand extends Command
{
private FilterOptions $filter;
private Readline $readline;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->filter = new FilterOptions();
parent::__construct($name);
}
/**
* Set the Shell's Readline service.
*
* @param Readline $readline
*/
public function setReadline(Readline $readline)
{
$this->readline = $readline;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
list($grep, $insensitive, $invert) = FilterOptions::getOptions();
$this
->setName('history')
->setAliases(['hist'])
->setDefinition([
new InputOption('show', 's', InputOption::VALUE_REQUIRED, 'Show the given range of lines.'),
new InputOption('head', 'H', InputOption::VALUE_REQUIRED, 'Display the first N items.'),
new InputOption('tail', 'T', InputOption::VALUE_REQUIRED, 'Display the last N items.'),
$grep,
$insensitive,
$invert,
new InputOption('no-numbers', 'N', InputOption::VALUE_NONE, 'Omit line numbers.'),
new InputOption('save', '', InputOption::VALUE_REQUIRED, 'Save history to a file.'),
new InputOption('replay', '', InputOption::VALUE_NONE, 'Replay.'),
new InputOption('clear', '', InputOption::VALUE_NONE, 'Clear the history.'),
])
->setDescription('Show the Psy Shell history.')
->setHelp(
<<<'HELP'
Show, search, save or replay the Psy Shell history.
e.g.
<return>>>> history --grep /[bB]acon/</return>
<return>>>> history --show 0..10 --replay</return>
<return>>>> history --clear</return>
<return>>>> history --tail 1000 --save somefile.txt</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->validateOnlyOne($input, ['show', 'head', 'tail']);
$this->validateOnlyOne($input, ['save', 'replay', 'clear']);
// For --show, slice first (uses original line numbers), then filter
$show = $input->getOption('show');
// For --head/--tail, filter first, then slice (uses result count)
$head = $input->getOption('head');
$tail = $input->getOption('tail');
$history = $this->getHistorySlice($show);
$highlighted = false;
$this->filter->bind($input);
if ($this->filter->hasFilter()) {
$matches = [];
$highlighted = [];
foreach ($history as $i => $line) {
if ($this->filter->match($line, $matches)) {
if (isset($matches[0])) {
$chunks = \explode($matches[0], $history[$i]);
$chunks = \array_map([__CLASS__, 'escape'], $chunks);
$glue = \sprintf('<urgent>%s</urgent>', self::escape($matches[0]));
$highlighted[$i] = \implode($glue, $chunks);
}
} else {
unset($history[$i]);
unset($highlighted[$i]);
}
}
}
$history = $this->applyHeadOrTail($history, $head, $tail);
if ($highlighted) {
$highlighted = $this->applyHeadOrTail($highlighted, $head, $tail);
}
if ($save = $input->getOption('save')) {
$output->writeln(\sprintf('Saving history in %s...', ConfigPaths::prettyPath($save)));
\file_put_contents($save, \implode(\PHP_EOL, $history).\PHP_EOL);
$output->writeln('<info>History saved.</info>');
} elseif ($input->getOption('replay')) {
if (!($input->getOption('show') || $input->getOption('head') || $input->getOption('tail'))) {
throw new \InvalidArgumentException('You must limit history via --head, --tail or --show before replaying');
}
$count = \count($history);
$output->writeln(\sprintf('Replaying %d line%s of history', $count, ($count !== 1) ? 's' : ''));
$this->getShell()->addInput($history);
} elseif ($input->getOption('clear')) {
$this->clearHistory();
$output->writeln('<info>History cleared.</info>');
} else {
$type = $input->getOption('no-numbers') ? 0 : ShellOutput::NUMBER_LINES;
if (!$highlighted) {
$type = $type | OutputInterface::OUTPUT_RAW;
}
$output->page($highlighted ?: $history, $type);
}
return 0;
}
/**
* Extract a range from a string.
*
* @param string $range
*
* @return int[] [ start, end ]
*/
private function extractRange(string $range): array
{
if (\preg_match('/^\d+$/', $range)) {
return [(int) $range, (int) $range + 1];
}
$matches = [];
if ($range !== '..' && \preg_match('/^(\d*)\.\.(\d*)$/', $range, $matches)) {
$start = $matches[1] ? (int) $matches[1] : 0;
$end = $matches[2] ? (int) $matches[2] + 1 : \PHP_INT_MAX;
return [$start, $end];
}
throw new \InvalidArgumentException('Unexpected range: '.$range);
}
/**
* Retrieve a slice of the readline history by range.
*
* @param string|null $show Range specification (e.g., "5..10")
*
* @return array A slice of history
*/
private function getHistorySlice(?string $show): array
{
$history = $this->readline->listHistory();
// don't show the current `history` invocation
\array_pop($history);
if ($show === null) {
return $history;
}
list($start, $end) = $this->extractRange($show);
$length = $end - $start;
return \array_slice($history, $start, $length, true);
}
/**
* Apply --head or --tail to a history array.
*/
private function applyHeadOrTail(array $history, ?string $head, ?string $tail): array
{
if ($head) {
if (!\preg_match('/^\d+$/', $head)) {
throw new \InvalidArgumentException('Please specify an integer argument for --head');
}
return \array_slice($history, 0, (int) $head, true);
} elseif ($tail) {
if (!\preg_match('/^\d+$/', $tail)) {
throw new \InvalidArgumentException('Please specify an integer argument for --tail');
}
$start = \count($history) - (int) $tail;
$length = (int) $tail + 1;
return \array_slice($history, $start, $length, true);
}
return $history;
}
/**
* Validate that only one of the given $options is set.
*
* @param InputInterface $input
* @param array $options
*/
private function validateOnlyOne(InputInterface $input, array $options)
{
$count = 0;
foreach ($options as $opt) {
if ($input->getOption($opt)) {
$count++;
}
}
if ($count > 1) {
throw new \InvalidArgumentException('Please specify only one of --'.\implode(', --', $options));
}
}
/**
* Clear the readline history.
*/
private function clearHistory()
{
$this->readline->clearHistory();
}
public static function escape(string $string): string
{
return OutputFormatter::escape($string);
}
}

View File

@ -0,0 +1,286 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Command\ListCommand\ClassConstantEnumerator;
use Psy\Command\ListCommand\ClassEnumerator;
use Psy\Command\ListCommand\ConstantEnumerator;
use Psy\Command\ListCommand\FunctionEnumerator;
use Psy\Command\ListCommand\GlobalVariableEnumerator;
use Psy\Command\ListCommand\MethodEnumerator;
use Psy\Command\ListCommand\PropertyEnumerator;
use Psy\Command\ListCommand\VariableEnumerator;
use Psy\Exception\RuntimeException;
use Psy\Input\CodeArgument;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\VarDumper\Presenter;
use Psy\VarDumper\PresenterAware;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* List available local variables, object properties, etc.
*/
class ListCommand extends ReflectingCommand implements PresenterAware
{
protected Presenter $presenter;
protected array $enumerators;
/**
* PresenterAware interface.
*
* @param Presenter $presenter
*/
public function setPresenter(Presenter $presenter)
{
$this->presenter = $presenter;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
list($grep, $insensitive, $invert) = FilterOptions::getOptions();
$this
->setName('ls')
->setAliases(['dir'])
->setDefinition([
new CodeArgument('target', CodeArgument::OPTIONAL, 'A target class or object to list.'),
new InputOption('vars', '', InputOption::VALUE_NONE, 'Display variables.'),
new InputOption('constants', 'c', InputOption::VALUE_NONE, 'Display defined constants.'),
new InputOption('functions', 'f', InputOption::VALUE_NONE, 'Display defined functions.'),
new InputOption('classes', 'k', InputOption::VALUE_NONE, 'Display declared classes.'),
new InputOption('interfaces', 'I', InputOption::VALUE_NONE, 'Display declared interfaces.'),
new InputOption('traits', 't', InputOption::VALUE_NONE, 'Display declared traits.'),
new InputOption('no-inherit', '', InputOption::VALUE_NONE, 'Exclude inherited methods, properties and constants.'),
new InputOption('properties', 'p', InputOption::VALUE_NONE, 'Display class or object properties (public properties by default).'),
new InputOption('methods', 'm', InputOption::VALUE_NONE, 'Display class or object methods (public methods by default).'),
$grep,
$insensitive,
$invert,
new InputOption('globals', 'g', InputOption::VALUE_NONE, 'Include global variables.'),
new InputOption('internal', 'n', InputOption::VALUE_NONE, 'Limit to internal functions and classes.'),
new InputOption('user', 'u', InputOption::VALUE_NONE, 'Limit to user-defined constants, functions and classes.'),
new InputOption('category', 'C', InputOption::VALUE_REQUIRED, 'Limit to constants in a specific category (e.g. "date").'),
new InputOption('all', 'a', InputOption::VALUE_NONE, 'Include private and protected methods and properties.'),
new InputOption('long', 'l', InputOption::VALUE_NONE, 'List in long format: includes class names and method signatures.'),
])
->setDescription('List local, instance or class variables, methods and constants.')
->setHelp(
<<<'HELP'
List variables, constants, classes, interfaces, traits, functions, methods,
and properties.
Called without options, this will return a list of variables currently in scope.
If a target object is provided, list properties, constants and methods of that
target. If a class, interface or trait name is passed instead, list constants
and methods on that class.
e.g.
<return>>>> ls</return>
<return>>>> ls $foo</return>
<return>>>> ls -k --grep mongo -i</return>
<return>>>> ls -al ReflectionClass</return>
<return>>>> ls --constants --category date</return>
<return>>>> ls -l --functions --grep /^array_.*/</return>
<return>>>> ls -l --properties new DateTime()</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->validateInput($input);
$this->initEnumerators();
$method = $input->getOption('long') ? 'writeLong' : 'write';
if ($target = $input->getArgument('target')) {
list($target, $reflector) = $this->getTargetAndReflector($target, $output);
} else {
$reflector = null;
}
// @todo something cleaner than this :-/
if ($output instanceof ShellOutput && $input->getOption('long')) {
$output->startPaging();
}
foreach ($this->enumerators as $enumerator) {
$this->$method($output, $enumerator->enumerate($input, $reflector, $target));
}
if ($output instanceof ShellOutput && $input->getOption('long')) {
$output->stopPaging();
}
// Set some magic local variables
if ($reflector !== null) {
$this->setCommandScopeVariables($reflector);
}
return 0;
}
/**
* Initialize Enumerators.
*/
protected function initEnumerators()
{
if (!isset($this->enumerators)) {
$mgr = $this->presenter;
$this->enumerators = [
new ClassConstantEnumerator($mgr),
new ClassEnumerator($mgr),
new ConstantEnumerator($mgr),
new FunctionEnumerator($mgr),
new GlobalVariableEnumerator($mgr),
new PropertyEnumerator($mgr),
new MethodEnumerator($mgr),
new VariableEnumerator($mgr, $this->context),
];
}
}
/**
* Write the list items to $output.
*
* @param OutputInterface $output
* @param array $result List of enumerated items
*/
protected function write(OutputInterface $output, array $result)
{
if (\count($result) === 0) {
return;
}
$formatter = $output->getFormatter();
foreach ($result as $label => $items) {
// Pre-format each item individually to avoid O(n^2) performance
// in Symfony's OutputFormatter when processing large strings with many style tags.
$names = \array_map(function ($item) use ($formatter) {
return $formatter->format($this->formatItemName($item));
}, $items);
// Pre-format the label and join with pre-formatted names
$line = $formatter->format(\sprintf('<strong>%s</strong>: ', $label)).\implode(', ', $names);
// Write raw since we've already formatted everything
$output->writeln($line, OutputInterface::OUTPUT_RAW);
}
}
/**
* Write the list items to $output.
*
* Items are listed one per line, and include the item signature.
*
* @param OutputInterface $output
* @param array $result List of enumerated items
*/
protected function writeLong(OutputInterface $output, array $result)
{
if (\count($result) === 0) {
return;
}
$table = $this->getTable($output);
foreach ($result as $label => $items) {
$output->writeln('');
$output->writeln(\sprintf('<strong>%s:</strong>', $label));
$table->setRows([]);
foreach ($items as $item) {
$table->addRow([$this->formatItemName($item), $item['value']]);
}
$table->render();
}
}
/**
* Format an item name given its visibility.
*
* @param array $item
*/
private function formatItemName(array $item): string
{
return \sprintf('<%s>%s</%s>', $item['style'], OutputFormatter::escape($item['name']), $item['style']);
}
/**
* Validate that input options make sense, provide defaults when called without options.
*
* @throws RuntimeException if options are inconsistent
*
* @param InputInterface $input
*/
private function validateInput(InputInterface $input)
{
if (!$input->getArgument('target')) {
// if no target is passed, there can be no properties or methods
foreach (['properties', 'methods', 'no-inherit'] as $option) {
if ($input->getOption($option)) {
throw new RuntimeException('--'.$option.' does not make sense without a specified target');
}
}
foreach (['globals', 'vars', 'constants', 'functions', 'classes', 'interfaces', 'traits'] as $option) {
if ($input->getOption($option)) {
return;
}
}
// default to --vars if no other options are passed
$input->setOption('vars', true);
} else {
// if a target is passed, classes, functions, etc don't make sense
foreach (['vars', 'globals'] as $option) {
if ($input->getOption($option)) {
throw new RuntimeException('--'.$option.' does not make sense with a specified target');
}
}
// @todo ensure that 'functions', 'classes', 'interfaces', 'traits' only accept namespace target?
foreach (['constants', 'properties', 'methods', 'functions', 'classes', 'interfaces', 'traits'] as $option) {
if ($input->getOption($option)) {
return;
}
}
// default to --constants --properties --methods if no other options are passed
$input->setOption('constants', true);
$input->setOption('properties', true);
$input->setOption('methods', true);
}
}
}

View File

@ -0,0 +1,121 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Symfony\Component\Console\Input\InputInterface;
/**
* Class Constant Enumerator class.
*/
class ClassConstantEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// only list constants when a Reflector is present.
if ($reflector === null) {
return [];
}
// We can only list constants on actual class (or object) reflectors.
if (!$reflector instanceof \ReflectionClass) {
// @todo handle ReflectionExtension as well
return [];
}
// only list constants if we are specifically asked
if (!$input->getOption('constants')) {
return [];
}
$noInherit = $input->getOption('no-inherit');
$constants = $this->prepareConstants($this->getConstants($reflector, $noInherit));
if (empty($constants)) {
return [];
}
$ret = [];
$ret[$this->getKindLabel($reflector)] = $constants;
return $ret;
}
/**
* Get defined constants for the given class or object Reflector.
*
* @param \ReflectionClass $reflector
* @param bool $noInherit Exclude inherited constants
*
* @return array
*/
protected function getConstants(\ReflectionClass $reflector, bool $noInherit = false): array
{
$className = $reflector->getName();
$constants = [];
foreach ($reflector->getConstants() as $name => $constant) {
$constReflector = new \ReflectionClassConstant($reflector->name, $name);
if ($noInherit && $constReflector->getDeclaringClass()->getName() !== $className) {
continue;
}
$constants[$name] = $constReflector;
}
\ksort($constants, \SORT_NATURAL | \SORT_FLAG_CASE);
return $constants;
}
/**
* Prepare formatted constant array.
*
* @param array $constants
*
* @return array
*/
protected function prepareConstants(array $constants): array
{
// My kingdom for a generator.
$ret = [];
foreach ($constants as $name => $constant) {
if ($this->showItem($name)) {
$ret[$name] = [
'name' => $name,
'style' => self::IS_CONSTANT,
'value' => $this->presentRef($constant->getValue()),
];
}
}
return $ret;
}
/**
* Get a label for the particular kind of "class" represented.
*
* @param \ReflectionClass $reflector
*/
protected function getKindLabel(\ReflectionClass $reflector): string
{
if ($reflector->isInterface()) {
return 'Interface Constants';
} else {
return 'Class Constants';
}
}
}

View File

@ -0,0 +1,132 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Psy\Reflection\ReflectionNamespace;
use Symfony\Component\Console\Input\InputInterface;
/**
* Class Enumerator class.
*/
class ClassEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// if we have a reflector, ensure that it's a namespace reflector
if (($target !== null || $reflector !== null) && !$reflector instanceof ReflectionNamespace) {
return [];
}
$internal = $input->getOption('internal');
$user = $input->getOption('user');
$prefix = $reflector === null ? null : \strtolower($reflector->getName()).'\\';
$ret = [];
// only list classes, interfaces and traits if we are specifically asked
if ($input->getOption('classes')) {
$ret = \array_merge($ret, $this->filterClasses('Classes', \get_declared_classes(), $internal, $user, $prefix));
}
if ($input->getOption('interfaces')) {
$ret = \array_merge($ret, $this->filterClasses('Interfaces', \get_declared_interfaces(), $internal, $user, $prefix));
}
if ($input->getOption('traits')) {
$ret = \array_merge($ret, $this->filterClasses('Traits', \get_declared_traits(), $internal, $user, $prefix));
}
return \array_map([$this, 'prepareClasses'], \array_filter($ret));
}
/**
* Filter a list of classes, interfaces or traits.
*
* If $internal or $user is defined, results will be limited to internal or
* user-defined classes as appropriate.
*
* @param string $key
* @param array $classes
* @param bool $internal
* @param bool $user
* @param string|null $prefix
*
* @return array
*/
protected function filterClasses(string $key, array $classes, bool $internal, bool $user, ?string $prefix = null): array
{
$ret = [];
if ($internal) {
$ret['Internal '.$key] = \array_filter($classes, function ($class) use ($prefix) {
if ($prefix !== null && \strpos(\strtolower($class), $prefix) !== 0) {
return false;
}
$refl = new \ReflectionClass($class);
return $refl->isInternal();
});
}
if ($user) {
$ret['User '.$key] = \array_filter($classes, function ($class) use ($prefix) {
if ($prefix !== null && \strpos(\strtolower($class), $prefix) !== 0) {
return false;
}
$refl = new \ReflectionClass($class);
return !$refl->isInternal();
});
}
if (!$user && !$internal) {
$ret[$key] = \array_filter($classes, function ($class) use ($prefix) {
return $prefix === null || \strpos(\strtolower($class), $prefix) === 0;
});
}
return $ret;
}
/**
* Prepare formatted class array.
*
* @param array $classes
*
* @return array
*/
protected function prepareClasses(array $classes): array
{
\natcasesort($classes);
// My kingdom for a generator.
$ret = [];
foreach ($classes as $name) {
if ($this->showItem($name)) {
$ret[$name] = [
'name' => $name,
'style' => self::IS_CLASS,
'value' => $this->presentSignature($name),
];
}
}
return $ret;
}
}

View File

@ -0,0 +1,176 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Psy\Reflection\ReflectionNamespace;
use Symfony\Component\Console\Input\InputInterface;
/**
* Constant Enumerator class.
*/
class ConstantEnumerator extends Enumerator
{
// Because `Json` is ugly.
private const CATEGORY_LABELS = [
'libxml' => 'libxml',
'openssl' => 'OpenSSL',
'pcre' => 'PCRE',
'sqlite3' => 'SQLite3',
'curl' => 'cURL',
'dom' => 'DOM',
'ftp' => 'FTP',
'gd' => 'GD',
'gmp' => 'GMP',
'iconv' => 'iconv',
'json' => 'JSON',
'ldap' => 'LDAP',
'mbstring' => 'mbstring',
'odbc' => 'ODBC',
'pcntl' => 'PCNTL',
'pgsql' => 'pgsql',
'posix' => 'POSIX',
'mysqli' => 'mysqli',
'soap' => 'SOAP',
'exif' => 'EXIF',
'sysvmsg' => 'sysvmsg',
'xml' => 'XML',
'xsl' => 'XSL',
];
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// if we have a reflector, ensure that it's a namespace reflector
if (($target !== null || $reflector !== null) && !$reflector instanceof ReflectionNamespace) {
return [];
}
// only list constants if we are specifically asked
if (!$input->getOption('constants')) {
return [];
}
$user = $input->getOption('user');
$internal = $input->getOption('internal');
$category = $input->getOption('category');
if ($category) {
$category = \strtolower($category);
if ($category === 'internal') {
$internal = true;
$category = null;
} elseif ($category === 'user') {
$user = true;
$category = null;
}
}
$ret = [];
if ($user) {
$ret['User Constants'] = $this->getConstants('user');
}
if ($internal) {
$ret['Internal Constants'] = $this->getConstants('internal');
}
if ($category) {
$caseCategory = \array_key_exists($category, self::CATEGORY_LABELS) ? self::CATEGORY_LABELS[$category] : \ucfirst($category);
$label = $caseCategory.' Constants';
$ret[$label] = $this->getConstants($category);
}
if (!$user && !$internal && !$category) {
$ret['Constants'] = $this->getConstants();
}
if ($reflector !== null) {
$prefix = \strtolower($reflector->getName()).'\\';
foreach ($ret as $key => $names) {
foreach (\array_keys($names) as $name) {
if (\strpos(\strtolower($name), $prefix) !== 0) {
unset($ret[$key][$name]);
}
}
}
}
return \array_map([$this, 'prepareConstants'], \array_filter($ret));
}
/**
* Get defined constants.
*
* Optionally restrict constants to a given category, e.g. "date". If the
* category is "internal", include all non-user-defined constants.
*
* @param string|null $category
*
* @return array
*/
protected function getConstants(?string $category = null): array
{
if (!$category) {
return \get_defined_constants();
}
$consts = \get_defined_constants(true);
if ($category === 'internal') {
unset($consts['user']);
$values = \array_values($consts);
return $values ? \array_merge(...$values) : [];
}
foreach ($consts as $key => $value) {
if (\strtolower($key) === $category) {
return $value;
}
}
return [];
}
/**
* Prepare formatted constant array.
*
* @param array $constants
*
* @return array
*/
protected function prepareConstants(array $constants): array
{
// My kingdom for a generator.
$ret = [];
$names = \array_keys($constants);
\natcasesort($names);
foreach ($names as $name) {
if ($this->showItem($name)) {
$ret[$name] = [
'name' => $name,
'style' => self::IS_CONSTANT,
'value' => $this->presentRef($constants[$name]),
];
}
}
return $ret;
}
}

View File

@ -0,0 +1,106 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Psy\Formatter\SignatureFormatter;
use Psy\Input\FilterOptions;
use Psy\Util\Mirror;
use Psy\VarDumper\Presenter;
use Symfony\Component\Console\Input\InputInterface;
/**
* Abstract Enumerator class.
*/
abstract class Enumerator
{
// Output styles
const IS_PUBLIC = 'public';
const IS_PROTECTED = 'protected';
const IS_PRIVATE = 'private';
const IS_GLOBAL = 'global';
const IS_CONSTANT = 'const';
const IS_CLASS = 'class';
const IS_FUNCTION = 'function';
private FilterOptions $filter;
private Presenter $presenter;
/**
* Enumerator constructor.
*
* @param Presenter $presenter
*/
public function __construct(Presenter $presenter)
{
$this->filter = new FilterOptions();
$this->presenter = $presenter;
}
/**
* Return a list of categorized things with the given input options and target.
*
* @param InputInterface $input
* @param \Reflector|null $reflector
* @param mixed $target
*
* @return array
*/
public function enumerate(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
$this->filter->bind($input);
return $this->listItems($input, $reflector, $target);
}
/**
* Enumerate specific items with the given input options and target.
*
* Implementing classes should return an array of arrays:
*
* [
* 'Constants' => [
* 'FOO' => [
* 'name' => 'FOO',
* 'style' => 'public',
* 'value' => '123',
* ],
* ],
* ]
*
* @param InputInterface $input
* @param \Reflector|null $reflector
* @param mixed $target
*
* @return array
*/
abstract protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array;
protected function showItem($name)
{
return $this->filter->match($name);
}
protected function presentRef($value)
{
return $this->presenter->presentRef($value);
}
protected function presentSignature($target)
{
// This might get weird if the signature is actually for a reflector. Hrm.
if (!$target instanceof \Reflector) {
$target = Mirror::get($target);
}
return SignatureFormatter::format($target);
}
}

View File

@ -0,0 +1,116 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Psy\Reflection\ReflectionNamespace;
use Symfony\Component\Console\Input\InputInterface;
/**
* Function Enumerator class.
*/
class FunctionEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// if we have a reflector, ensure that it's a namespace reflector
if (($target !== null || $reflector !== null) && !$reflector instanceof ReflectionNamespace) {
return [];
}
// only list functions if we are specifically asked
if (!$input->getOption('functions')) {
return [];
}
if ($input->getOption('user')) {
$label = 'User Functions';
$functions = $this->getFunctions('user');
} elseif ($input->getOption('internal')) {
$label = 'Internal Functions';
$functions = $this->getFunctions('internal');
} else {
$label = 'Functions';
$functions = $this->getFunctions();
}
$prefix = $reflector === null ? null : \strtolower($reflector->getName()).'\\';
$functions = $this->prepareFunctions($functions, $prefix);
if (empty($functions)) {
return [];
}
$ret = [];
$ret[$label] = $functions;
return $ret;
}
/**
* Get defined functions.
*
* Optionally limit functions to "user" or "internal" functions.
*
* @param string|null $type "user" or "internal" (default: both)
*
* @return array
*/
protected function getFunctions(?string $type = null): array
{
$funcs = \get_defined_functions();
if ($type) {
return $funcs[$type];
} else {
return \array_merge($funcs['internal'], $funcs['user']);
}
}
/**
* Prepare formatted function array.
*
* @param array $functions
* @param string|null $prefix
*
* @return array
*/
protected function prepareFunctions(array $functions, ?string $prefix = null): array
{
\natcasesort($functions);
// My kingdom for a generator.
$ret = [];
foreach ($functions as $name) {
if ($prefix !== null && \strpos(\strtolower($name), $prefix) !== 0) {
continue;
}
if ($this->showItem($name)) {
try {
$ret[$name] = [
'name' => $name,
'style' => self::IS_FUNCTION,
'value' => $this->presentSignature($name),
];
} catch (\Throwable $e) {
// Ignore failures.
}
}
}
return $ret;
}
}

View File

@ -0,0 +1,92 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Symfony\Component\Console\Input\InputInterface;
/**
* Global Variable Enumerator class.
*/
class GlobalVariableEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// only list globals when no Reflector is present.
if ($reflector !== null || $target !== null) {
return [];
}
// only list globals if we are specifically asked
if (!$input->getOption('globals')) {
return [];
}
$globals = $this->prepareGlobals($this->getGlobals());
if (empty($globals)) {
return [];
}
return [
'Global Variables' => $globals,
];
}
/**
* Get defined global variables.
*
* @return array
*/
protected function getGlobals(): array
{
global $GLOBALS;
$names = \array_keys($GLOBALS);
\natcasesort($names);
$ret = [];
foreach ($names as $name) {
$ret[$name] = $GLOBALS[$name];
}
return $ret;
}
/**
* Prepare formatted global variable array.
*
* @param array $globals
*
* @return array
*/
protected function prepareGlobals(array $globals): array
{
// My kingdom for a generator.
$ret = [];
foreach ($globals as $name => $value) {
if ($this->showItem($name)) {
$fname = '$'.$name;
$ret[$fname] = [
'name' => $fname,
'style' => self::IS_GLOBAL,
'value' => $this->presentRef($value),
];
}
}
return $ret;
}
}

View File

@ -0,0 +1,142 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Symfony\Component\Console\Input\InputInterface;
/**
* Method Enumerator class.
*/
class MethodEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// only list methods when a Reflector is present.
if ($reflector === null) {
return [];
}
// We can only list methods on actual class (or object) reflectors.
if (!$reflector instanceof \ReflectionClass) {
return [];
}
// only list methods if we are specifically asked
if (!$input->getOption('methods')) {
return [];
}
$showAll = $input->getOption('all');
$noInherit = $input->getOption('no-inherit');
$methods = $this->prepareMethods($this->getMethods($showAll, $reflector, $noInherit));
if (empty($methods)) {
return [];
}
$ret = [];
$ret[$this->getKindLabel($reflector)] = $methods;
return $ret;
}
/**
* Get defined methods for the given class or object Reflector.
*
* @param bool $showAll Include private and protected methods
* @param \ReflectionClass $reflector
* @param bool $noInherit Exclude inherited methods
*
* @return array
*/
protected function getMethods(bool $showAll, \ReflectionClass $reflector, bool $noInherit = false): array
{
$className = $reflector->getName();
$methods = [];
foreach ($reflector->getMethods() as $name => $method) {
// For some reason PHP reflection shows private methods from the parent class, even
// though they're effectively worthless. Let's suppress them here, like --no-inherit
if (($noInherit || $method->isPrivate()) && $method->getDeclaringClass()->getName() !== $className) {
continue;
}
if ($showAll || $method->isPublic()) {
$methods[$method->getName()] = $method;
}
}
\ksort($methods, \SORT_NATURAL | \SORT_FLAG_CASE);
return $methods;
}
/**
* Prepare formatted method array.
*
* @param array $methods
*
* @return array
*/
protected function prepareMethods(array $methods): array
{
// My kingdom for a generator.
$ret = [];
foreach ($methods as $name => $method) {
if ($this->showItem($name)) {
$ret[$name] = [
'name' => $name,
'style' => $this->getVisibilityStyle($method),
'value' => $this->presentSignature($method),
];
}
}
return $ret;
}
/**
* Get a label for the particular kind of "class" represented.
*
* @param \ReflectionClass $reflector
*/
protected function getKindLabel(\ReflectionClass $reflector): string
{
if ($reflector->isInterface()) {
return 'Interface Methods';
} elseif (\method_exists($reflector, 'isTrait') && $reflector->isTrait()) {
return 'Trait Methods';
} else {
return 'Class Methods';
}
}
/**
* Get output style for the given method's visibility.
*
* @param \ReflectionMethod $method
*/
private function getVisibilityStyle(\ReflectionMethod $method): string
{
if ($method->isPublic()) {
return self::IS_PUBLIC;
} elseif ($method->isProtected()) {
return self::IS_PROTECTED;
} else {
return self::IS_PRIVATE;
}
}
}

View File

@ -0,0 +1,178 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Symfony\Component\Console\Input\InputInterface;
/**
* Property Enumerator class.
*/
class PropertyEnumerator extends Enumerator
{
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// only list properties when a Reflector is present.
if ($reflector === null) {
return [];
}
// We can only list properties on actual class (or object) reflectors.
if (!$reflector instanceof \ReflectionClass) {
return [];
}
// only list properties if we are specifically asked
if (!$input->getOption('properties')) {
return [];
}
$showAll = $input->getOption('all');
$noInherit = $input->getOption('no-inherit');
$properties = $this->prepareProperties($this->getProperties($showAll, $reflector, $noInherit), $target);
if (empty($properties)) {
return [];
}
$ret = [];
$ret[$this->getKindLabel($reflector)] = $properties;
return $ret;
}
/**
* Get defined properties for the given class or object Reflector.
*
* @param bool $showAll Include private and protected properties
* @param \ReflectionClass $reflector
* @param bool $noInherit Exclude inherited properties
*
* @return array
*/
protected function getProperties(bool $showAll, \ReflectionClass $reflector, bool $noInherit = false): array
{
$className = $reflector->getName();
$properties = [];
foreach ($reflector->getProperties() as $property) {
if ($noInherit && $property->getDeclaringClass()->getName() !== $className) {
continue;
}
if ($showAll || $property->isPublic()) {
$properties[$property->getName()] = $property;
}
}
\ksort($properties, \SORT_NATURAL | \SORT_FLAG_CASE);
return $properties;
}
/**
* Prepare formatted property array.
*
* @param array $properties
*
* @return array
*/
protected function prepareProperties(array $properties, $target = null): array
{
// My kingdom for a generator.
$ret = [];
foreach ($properties as $name => $property) {
if ($this->showItem($name)) {
$fname = '$'.$name;
$ret[$fname] = [
'name' => $fname,
'style' => $this->getVisibilityStyle($property),
'value' => $this->presentValue($property, $target),
];
}
}
return $ret;
}
/**
* Get a label for the particular kind of "class" represented.
*
* @param \ReflectionClass $reflector
*/
protected function getKindLabel(\ReflectionClass $reflector): string
{
if (\method_exists($reflector, 'isTrait') && $reflector->isTrait()) {
return 'Trait Properties';
} else {
return 'Class Properties';
}
}
/**
* Get output style for the given property's visibility.
*
* @param \ReflectionProperty $property
*/
private function getVisibilityStyle(\ReflectionProperty $property): string
{
if ($property->isPublic()) {
return self::IS_PUBLIC;
} elseif ($property->isProtected()) {
return self::IS_PROTECTED;
} else {
return self::IS_PRIVATE;
}
}
/**
* Present the $target's current value for a reflection property.
*
* @param \ReflectionProperty $property
* @param mixed $target
*/
protected function presentValue(\ReflectionProperty $property, $target): string
{
if (!$target) {
return '';
}
// If $target is a class or trait (try to) get the default
// value for the property.
if (!\is_object($target)) {
try {
$refl = new \ReflectionClass($target);
$props = $refl->getDefaultProperties();
if (\array_key_exists($property->name, $props)) {
$suffix = $property->isStatic() ? '' : ' <aside>(default)</aside>';
return $this->presentRef($props[$property->name]).$suffix;
}
} catch (\Throwable $e) {
// Well, we gave it a shot.
}
return '';
}
if (\PHP_VERSION_ID < 80100) {
$property->setAccessible(true);
}
$value = $property->getValue($target);
return $this->presentRef($value);
}
}

View File

@ -0,0 +1,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\ListCommand;
use Psy\Context;
use Psy\VarDumper\Presenter;
use Symfony\Component\Console\Input\InputInterface;
/**
* Variable Enumerator class.
*/
class VariableEnumerator extends Enumerator
{
// n.b. this array is the order in which special variables will be listed
private const SPECIAL_NAMES = [
'_', '_e', '__out', '__function', '__method', '__class', '__namespace', '__file', '__line', '__dir',
];
private $context;
/**
* Variable Enumerator constructor.
*
* Unlike most other enumerators, the Variable Enumerator needs access to
* the current scope variables, so we need to pass it a Context instance.
*
* @param Presenter $presenter
* @param Context $context
*/
public function __construct(Presenter $presenter, Context $context)
{
$this->context = $context;
parent::__construct($presenter);
}
/**
* {@inheritdoc}
*/
protected function listItems(InputInterface $input, ?\Reflector $reflector = null, $target = null): array
{
// only list variables when no Reflector is present.
if ($reflector !== null || $target !== null) {
return [];
}
// only list variables if we are specifically asked
if (!$input->getOption('vars')) {
return [];
}
$showAll = $input->getOption('all');
$variables = $this->prepareVariables($this->getVariables($showAll));
if (empty($variables)) {
return [];
}
return [
'Variables' => $variables,
];
}
/**
* Get scope variables.
*
* @param bool $showAll Include special variables (e.g. $_)
*
* @return array
*/
protected function getVariables(bool $showAll): array
{
$scopeVars = $this->context->getAll();
\uksort($scopeVars, function ($a, $b) {
$aIndex = \array_search($a, self::SPECIAL_NAMES);
$bIndex = \array_search($b, self::SPECIAL_NAMES);
if ($aIndex !== false) {
if ($bIndex !== false) {
return $aIndex - $bIndex;
}
return 1;
}
if ($bIndex !== false) {
return -1;
}
return \strnatcasecmp($a, $b);
});
$ret = [];
foreach ($scopeVars as $name => $val) {
if (!$showAll && \in_array($name, self::SPECIAL_NAMES)) {
continue;
}
$ret[$name] = $val;
}
return $ret;
}
/**
* Prepare formatted variable array.
*
* @param array $variables
*
* @return array
*/
protected function prepareVariables(array $variables): array
{
// My kingdom for a generator.
$ret = [];
foreach ($variables as $name => $val) {
if ($this->showItem($name)) {
$fname = '$'.$name;
$ret[$fname] = [
'name' => $fname,
'style' => \in_array($name, self::SPECIAL_NAMES) ? self::IS_PRIVATE : self::IS_PUBLIC,
'value' => $this->presentRef($val),
];
}
}
return $ret;
}
}

View File

@ -0,0 +1,141 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\Node;
use PhpParser\Parser;
use Psy\Context;
use Psy\ContextAware;
use Psy\Input\CodeArgument;
use Psy\ParserFactory;
use Psy\VarDumper\Presenter;
use Psy\VarDumper\PresenterAware;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\VarDumper\Caster\Caster;
/**
* Parse PHP code and show the abstract syntax tree.
*/
class ParseCommand extends Command implements ContextAware, PresenterAware
{
protected Context $context;
private Presenter $presenter;
private Parser $parser;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->parser = (new ParserFactory())->createParser();
parent::__construct($name);
}
/**
* ContextAware interface.
*
* @param Context $context
*/
public function setContext(Context $context)
{
$this->context = $context;
}
/**
* PresenterAware interface.
*
* @param Presenter $presenter
*/
public function setPresenter(Presenter $presenter)
{
$this->presenter = clone $presenter;
$this->presenter->addCasters([
Node::class => function (Node $node, array $a) {
$a = [
Caster::PREFIX_VIRTUAL.'type' => $node->getType(),
Caster::PREFIX_VIRTUAL.'attributes' => $node->getAttributes(),
];
foreach ($node->getSubNodeNames() as $name) {
$a[Caster::PREFIX_VIRTUAL.$name] = $node->$name;
}
return $a;
},
]);
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('parse')
->setDefinition([
new CodeArgument('code', CodeArgument::REQUIRED, 'PHP code to parse.'),
new InputOption('depth', '', InputOption::VALUE_REQUIRED, 'Depth to parse.', 10),
])
->setDescription('Parse PHP code and show the abstract syntax tree.')
->setHelp(
<<<'HELP'
Parse PHP code and show the abstract syntax tree.
This command is used in the development of PsySH. Given a string of PHP code,
it pretty-prints the PHP Parser parse tree.
See https://github.com/nikic/PHP-Parser
It prolly won't be super useful for most of you, but it's here if you want to play.
HELP
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$code = $input->getArgument('code');
$depth = $input->getOption('depth');
if (!\preg_match('/^\s*<\\?/', $code)) {
$code = '<?php '.$code;
}
try {
$nodes = $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
if ($this->parseErrorIsEOF($e)) {
$nodes = $this->parser->parse($code.';');
} else {
throw $e;
}
}
$output->page($this->presenter->present($nodes, $depth));
$this->context->setReturnValue($nodes);
return 0;
}
private function parseErrorIsEOF(\PhpParser\Error $e): bool
{
$msg = $e->getRawMessage();
return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* A dumb little command for printing out the current Psy Shell version.
*/
class PsyVersionCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('version')
->setDefinition([])
->setDescription('Show Psy Shell version.')
->setHelp('Show Psy Shell version.');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln($this->getApplication()->getVersion());
return 0;
}
}

View File

@ -0,0 +1,364 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\NodeTraverser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\CodeCleaner;
use Psy\CodeCleaner\NoReturnValue;
use Psy\CodeCleanerAware;
use Psy\Context;
use Psy\ContextAware;
use Psy\Exception\ErrorException;
use Psy\Exception\RuntimeException;
use Psy\Exception\UnexpectedTargetException;
use Psy\Reflection\ReflectionConstant;
use Psy\Sudo\SudoVisitor;
use Psy\Util\Mirror;
use Psy\Util\Str;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* An abstract command with helpers for inspecting the current context.
*/
abstract class ReflectingCommand extends Command implements ContextAware, CodeCleanerAware
{
const CLASS_OR_FUNC = '/^[\\\\\w]+$/';
const CLASS_MEMBER = '/^([\\\\\w]+)::(\w+)$/';
const CLASS_STATIC = '/^([\\\\\w]+)::\$(\w+)$/';
const INSTANCE_MEMBER = '/^(\$\w+)(::|->)(\w+)$/';
protected Context $context;
protected CodeCleaner $cleaner;
private CodeArgumentParser $parser;
private NodeTraverser $traverser;
private Printer $printer;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->parser = new CodeArgumentParser();
// @todo Pass visitor directly to once we drop support for PHP-Parser 4.x
$this->traverser = new NodeTraverser();
$this->traverser->addVisitor(new SudoVisitor());
$this->printer = new Printer();
parent::__construct($name);
}
/**
* ContextAware interface.
*
* @param Context $context
*/
public function setContext(Context $context)
{
$this->context = $context;
}
/**
* CodeCleanerAware interface.
*/
public function setCodeCleaner(CodeCleaner $cleaner)
{
$this->cleaner = $cleaner;
}
/**
* Get the target for a value.
*
* @throws \InvalidArgumentException when the value specified can't be resolved
*
* @param string $valueName Function, class, variable, constant, method or property name
*
* @return array (class or instance name, member name, kind)
*/
protected function getTarget(string $valueName): array
{
$valueName = \trim($valueName);
$matches = [];
switch (true) {
case \preg_match(self::CLASS_OR_FUNC, $valueName, $matches):
return [$this->resolveName($matches[0], true), null, 0];
case \preg_match(self::CLASS_MEMBER, $valueName, $matches):
return [$this->resolveName($matches[1]), $matches[2], Mirror::CONSTANT | Mirror::METHOD];
case \preg_match(self::CLASS_STATIC, $valueName, $matches):
return [$this->resolveName($matches[1]), $matches[2], Mirror::STATIC_PROPERTY | Mirror::PROPERTY];
case \preg_match(self::INSTANCE_MEMBER, $valueName, $matches):
if ($matches[2] === '->') {
$kind = Mirror::METHOD | Mirror::PROPERTY;
} else {
$kind = Mirror::CONSTANT | Mirror::METHOD;
}
return [$this->resolveObject($matches[1]), $matches[3], $kind];
default:
return [$this->resolveObject($valueName), null, 0];
}
}
/**
* Resolve a class or function name (with the current shell namespace).
*
* @throws ErrorException when `self` or `static` is used in a non-class scope
*
* @param string $name
* @param bool $includeFunctions (default: false)
*/
protected function resolveName(string $name, bool $includeFunctions = false): string
{
$shell = $this->getShell();
// While not *technically* 100% accurate, let's treat `self` and `static` as equivalent.
if (\in_array(\strtolower($name), ['self', 'static'])) {
if ($boundClass = $shell->getBoundClass()) {
return $boundClass;
}
if ($boundObject = $shell->getBoundObject()) {
return \get_class($boundObject);
}
$msg = \sprintf('Cannot use "%s" when no class scope is active', \strtolower($name));
throw new ErrorException($msg, 0, \E_USER_ERROR, "eval()'d code", 1);
}
if (\substr($name, 0, 1) === '\\') {
return $name;
}
// Use CodeCleaner to resolve the name through use statements and namespace
if (Str::isValidClassName($name)) {
$resolved = $this->cleaner->resolveClassName($name);
// If we got a different name back, use it
if ($resolved !== $name) {
return $resolved;
}
// Fall back to the old resolveCode approach for edge cases
try {
$resolved = $this->resolveCode($name.'::class');
if ($resolved !== $name) {
return $resolved;
}
} catch (RuntimeException $e) {
// Fall through to namespace check
}
}
if ($namespace = $shell->getNamespace()) {
$fullName = $namespace.'\\'.$name;
if (\class_exists($fullName) || \interface_exists($fullName) || ($includeFunctions && \function_exists($fullName))) {
return $fullName;
}
}
return $name;
}
/**
* Get a Reflector and documentation for a function, class or instance, constant, method or property.
*
* @param string $valueName Function, class, variable, constant, method or property name
* @param OutputInterface|null $output Optional output for displaying cleaner messages
*
* @return array (value, Reflector)
*/
protected function getTargetAndReflector(string $valueName, ?OutputInterface $output = null): array
{
list($value, $member, $kind) = $this->getTarget($valueName);
// Display any implicit use statements that were added during name resolution
if ($output !== null) {
$this->writeCleanerMessages($output);
}
return [$value, Mirror::get($value, $member, $kind)];
}
/**
* Resolve code to a value in the current scope.
*
* @throws RuntimeException when the code does not return a value in the current scope
*
* @param string $code
*
* @return mixed Variable value
*/
protected function resolveCode(string $code)
{
try {
// Add an implicit `sudo` to target resolution.
$nodes = $this->traverser->traverse($this->parser->parse($code));
$sudoCode = $this->printer->prettyPrint($nodes);
$value = $this->getShell()->execute($sudoCode, true);
} catch (\Throwable $e) {
// Swallow all exceptions?
}
if (!isset($value) || $value instanceof NoReturnValue) {
throw new RuntimeException('Unknown target: '.$code);
}
return $value;
}
/**
* Resolve code to an object in the current scope.
*
* @throws UnexpectedTargetException when the code resolves to a non-object value
*
* @param string $code
*
* @return object Variable instance
*/
private function resolveObject(string $code)
{
$value = $this->resolveCode($code);
if (!\is_object($value)) {
throw new UnexpectedTargetException($value, 'Unable to inspect a non-object');
}
return $value;
}
/**
* Get a variable from the current shell scope.
*
* @param string $name
*
* @return mixed
*/
protected function getScopeVariable(string $name)
{
return $this->context->get($name);
}
/**
* Get all scope variables from the current shell scope.
*
* @return array
*/
protected function getScopeVariables(): array
{
return $this->context->getAll();
}
/**
* Given a Reflector instance, set command-scope variables in the shell
* execution context. This is used to inject magic $__class, $__method and
* $__file variables (as well as a handful of others).
*
* @param \Reflector $reflector
*/
protected function setCommandScopeVariables(\Reflector $reflector)
{
$vars = [];
switch (\get_class($reflector)) {
case \ReflectionClass::class:
case \ReflectionObject::class:
$vars['__class'] = $reflector->name;
if ($reflector->inNamespace()) {
$vars['__namespace'] = $reflector->getNamespaceName();
}
break;
case \ReflectionMethod::class:
$vars['__method'] = \sprintf('%s::%s', $reflector->class, $reflector->name);
$vars['__class'] = $reflector->class;
$classReflector = $reflector->getDeclaringClass();
if ($classReflector->inNamespace()) {
$vars['__namespace'] = $classReflector->getNamespaceName();
}
break;
case \ReflectionFunction::class:
$vars['__function'] = $reflector->name;
if ($reflector->inNamespace()) {
$vars['__namespace'] = $reflector->getNamespaceName();
}
break;
case \ReflectionGenerator::class:
$funcReflector = $reflector->getFunction();
$vars['__function'] = $funcReflector->name;
if ($funcReflector->inNamespace()) {
$vars['__namespace'] = $funcReflector->getNamespaceName();
}
if ($fileName = $reflector->getExecutingFile()) {
$vars['__file'] = $fileName;
$vars['__line'] = $reflector->getExecutingLine();
$vars['__dir'] = \dirname($fileName);
}
break;
case \ReflectionProperty::class:
case \ReflectionClassConstant::class:
$classReflector = $reflector->getDeclaringClass();
$vars['__class'] = $classReflector->name;
if ($classReflector->inNamespace()) {
$vars['__namespace'] = $classReflector->getNamespaceName();
}
// no line for these, but this'll do
if ($fileName = $reflector->getDeclaringClass()->getFileName()) {
$vars['__file'] = $fileName;
$vars['__dir'] = \dirname($fileName);
}
break;
case ReflectionConstant::class:
if ($reflector->inNamespace()) {
$vars['__namespace'] = $reflector->getNamespaceName();
}
break;
}
if ($reflector instanceof \ReflectionClass || $reflector instanceof \ReflectionFunctionAbstract) {
if ($fileName = $reflector->getFileName()) {
$vars['__file'] = $fileName;
$vars['__line'] = $reflector->getStartLine();
$vars['__dir'] = \dirname($fileName);
}
}
$this->context->setCommandScopeVariables($vars);
}
/**
* Write log messages (e.g. implicit use statements) from CodeCleaner passes.
*/
protected function writeCleanerMessages(OutputInterface $output)
{
// Write to stderr if this is a ConsoleOutput
if ($output instanceof ConsoleOutput) {
$output = $output->getErrorOutput();
}
foreach ($this->cleaner->getMessages() as $message) {
$output->writeln(\sprintf('<whisper>%s</whisper>', OutputFormatter::escape($message)));
}
}
}

View File

@ -0,0 +1,293 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Exception\RuntimeException;
use Psy\Exception\UnexpectedTargetException;
use Psy\Formatter\CodeFormatter;
use Psy\Formatter\SignatureFormatter;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Show the code for an object, class, constant, method or property.
*/
class ShowCommand extends ReflectingCommand
{
private ?\Throwable $lastException = null;
private ?int $lastExceptionIndex = null;
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('show')
->setDefinition([
new CodeArgument('target', CodeArgument::OPTIONAL, 'Function, class, instance, constant, method or property to show.'),
new InputOption('ex', null, InputOption::VALUE_OPTIONAL, 'Show last exception context. Optionally specify a stack index.', 1),
])
->setDescription('Show the code for an object, class, constant, method or property.')
->setHelp(
<<<HELP
Show the code for an object, class, constant, method or property, or the context
of the last exception.
<return>show --ex</return> defaults to showing the lines surrounding the location of the last
exception. Invoking it more than once travels up the exception's stack trace,
and providing a number shows the context of the given index of the trace.
e.g.
<return>>>> show \$myObject</return>
<return>>>> show Psy\Shell::debug</return>
<return>>>> show --ex</return>
<return>>>> show --ex 3</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
// n.b. As far as I can tell, InputInterface doesn't want to tell me
// whether an option with an optional value was actually passed. If you
// call `$input->getOption('ex')`, it will return the default, both when
// `--ex` is specified with no value, and when `--ex` isn't specified at
// all.
//
// So we're doing something sneaky here. If we call `getOptions`, it'll
// return the default value when `--ex` is not present, and `null` if
// `--ex` is passed with no value. /shrug
$opts = $input->getOptions();
// Strict comparison to `1` (the default value) here, because `--ex 1`
// will come in as `"1"`. Now we can tell the difference between
// "no --ex present", because it's the integer 1, "--ex with no value",
// because it's `null`, and "--ex 1", because it's the string "1".
if ($opts['ex'] !== 1) {
if ($input->getArgument('target')) {
throw new \InvalidArgumentException('Too many arguments (supply either "target" or "--ex")');
}
$this->writeExceptionContext($input, $output);
return 0;
}
if ($input->getArgument('target')) {
$this->writeCodeContext($input, $output);
return 0;
}
throw new RuntimeException('Not enough arguments (missing: "target")');
}
private function writeCodeContext(InputInterface $input, OutputInterface $output)
{
try {
list($target, $reflector) = $this->getTargetAndReflector($input->getArgument('target'), $output);
} catch (UnexpectedTargetException $e) {
// If we didn't get a target and Reflector, maybe we got a filename?
$target = $e->getTarget();
if (\is_string($target) && \is_file($target) && $code = @\file_get_contents($target)) {
$file = \realpath($target);
if ($file !== $this->context->get('__file')) {
$this->context->setCommandScopeVariables([
'__file' => $file,
'__dir' => \dirname($file),
]);
}
$output->page(CodeFormatter::formatCode($code));
return;
} else {
throw $e;
}
}
// Set some magic local variables
$this->setCommandScopeVariables($reflector);
try {
$output->page(CodeFormatter::format($reflector));
} catch (RuntimeException $e) {
$output->writeln(SignatureFormatter::format($reflector));
throw $e;
}
}
private function writeExceptionContext(InputInterface $input, OutputInterface $output)
{
$exception = $this->context->getLastException();
if ($exception !== $this->lastException) {
$this->lastException = null;
$this->lastExceptionIndex = null;
}
$opts = $input->getOptions();
if ($opts['ex'] === null) {
if ($this->lastException && $this->lastExceptionIndex !== null) {
$index = $this->lastExceptionIndex + 1;
} else {
$index = 0;
}
} else {
$index = \max(0, (int) $input->getOption('ex') - 1);
}
$trace = $exception->getTrace();
\array_unshift($trace, [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
if ($index >= \count($trace)) {
$index = 0;
}
$this->lastException = $exception;
$this->lastExceptionIndex = $index;
$output->writeln($this->getShell()->formatException($exception));
$output->writeln('--');
$this->writeTraceLine($output, $trace, $index);
$this->writeTraceCodeSnippet($output, $trace, $index);
$this->setCommandScopeVariablesFromContext($trace[$index]);
}
private function writeTraceLine(OutputInterface $output, array $trace, $index)
{
$file = isset($trace[$index]['file']) ? $this->replaceCwd($trace[$index]['file']) : 'n/a';
$line = isset($trace[$index]['line']) ? $trace[$index]['line'] : 'n/a';
$output->writeln(\sprintf(
'From <info>%s:%d</info> at <strong>level %d</strong> of backtrace (of %d):',
OutputFormatter::escape($file),
OutputFormatter::escape($line),
$index + 1,
\count($trace)
));
}
private function replaceCwd(string $file): string
{
if ($cwd = \getcwd()) {
$cwd = \rtrim($cwd, \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR;
}
if ($cwd === false) {
return $file;
} else {
return \preg_replace('/^'.\preg_quote($cwd, '/').'/', '', $file);
}
}
private function writeTraceCodeSnippet(OutputInterface $output, array $trace, $index)
{
if (!isset($trace[$index]['file'])) {
return;
}
$file = $trace[$index]['file'];
if ($fileAndLine = $this->extractEvalFileAndLine($file)) {
list($file, $line) = $fileAndLine;
} else {
if (!isset($trace[$index]['line'])) {
return;
}
$line = $trace[$index]['line'];
}
if (\is_file($file)) {
$code = @\file_get_contents($file);
}
if (empty($code)) {
return;
}
$startLine = \max($line - 5, 0);
$endLine = $line + 5;
$output->write(CodeFormatter::formatCode($code, $startLine, $endLine, $line), false);
}
private function setCommandScopeVariablesFromContext(array $context)
{
$vars = [];
if (isset($context['class'])) {
$vars['__class'] = $context['class'];
if (isset($context['function'])) {
$vars['__method'] = $context['function'];
}
try {
$refl = new \ReflectionClass($context['class']);
if ($namespace = $refl->getNamespaceName()) {
$vars['__namespace'] = $namespace;
}
} catch (\Throwable $e) {
// oh well
}
} elseif (isset($context['function'])) {
$vars['__function'] = $context['function'];
try {
$refl = new \ReflectionFunction($context['function']);
if ($namespace = $refl->getNamespaceName()) {
$vars['__namespace'] = $namespace;
}
} catch (\Throwable $e) {
// oh well
}
}
if (isset($context['file'])) {
$file = $context['file'];
if ($fileAndLine = $this->extractEvalFileAndLine($file)) {
list($file, $line) = $fileAndLine;
} elseif (isset($context['line'])) {
$line = $context['line'];
}
if (\is_file($file)) {
$vars['__file'] = $file;
if (isset($line)) {
$vars['__line'] = $line;
}
$vars['__dir'] = \dirname($file);
}
}
$this->context->setCommandScopeVariables($vars);
}
private function extractEvalFileAndLine(string $file)
{
if (\preg_match('/(.*)\\((\\d+)\\) : eval\\(\\)\'d code$/', $file, $matches)) {
return [$matches[1], $matches[2]];
}
}
}

View File

@ -0,0 +1,122 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\NodeTraverser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\Input\CodeArgument;
use Psy\Readline\Readline;
use Psy\Sudo\SudoVisitor;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Evaluate PHP code, bypassing visibility restrictions.
*/
class SudoCommand extends Command
{
private Readline $readline;
private CodeArgumentParser $parser;
private NodeTraverser $traverser;
private Printer $printer;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->parser = new CodeArgumentParser();
// @todo Pass visitor directly to once we drop support for PHP-Parser 4.x
$this->traverser = new NodeTraverser();
$this->traverser->addVisitor(new SudoVisitor());
$this->printer = new Printer();
parent::__construct($name);
}
/**
* Set the Shell's Readline service.
*
* @param Readline $readline
*/
public function setReadline(Readline $readline)
{
$this->readline = $readline;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('sudo')
->setDefinition([
new CodeArgument('code', CodeArgument::REQUIRED, 'Code to execute.'),
])
->setDescription('Evaluate PHP code, bypassing visibility restrictions.')
->setHelp(
<<<'HELP'
Evaluate PHP code, bypassing visibility restrictions.
e.g.
<return>>>> $sekret->whisper("hi")</return>
<return>PHP error: Call to private method Sekret::whisper() from context '' on line 1</return>
<return>>>> sudo $sekret->whisper("hi")</return>
<return>=> "hi"</return>
<return>>>> $sekret->word</return>
<return>PHP error: Cannot access private property Sekret::$word on line 1</return>
<return>>>> sudo $sekret->word</return>
<return>=> "hi"</return>
<return>>>> $sekret->word = "please"</return>
<return>PHP error: Cannot access private property Sekret::$word on line 1</return>
<return>>>> sudo $sekret->word = "please"</return>
<return>=> "please"</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$code = $input->getArgument('code');
// special case for !!
if ($code === '!!') {
$history = $this->readline->listHistory();
if (\count($history) < 2) {
throw new \InvalidArgumentException('No previous command to replay');
}
$code = $history[\count($history) - 2];
}
$nodes = $this->traverser->traverse($this->parser->parse($code));
$sudoCode = $this->printer->prettyPrint($nodes);
$shell = $this->getShell();
$shell->addCode($sudoCode, !$shell->hasCode());
return 0;
}
}

View File

@ -0,0 +1,126 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\Throw_;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\Exception\ThrowUpException;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Throw an exception or error out of the Psy Shell.
*/
class ThrowUpCommand extends Command
{
private CodeArgumentParser $parser;
private Printer $printer;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->parser = new CodeArgumentParser();
$this->printer = new Printer();
parent::__construct($name);
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('throw-up')
->setDefinition([
new CodeArgument('exception', CodeArgument::OPTIONAL, 'Exception or Error to throw.'),
])
->setDescription('Throw an exception or error out of the Psy Shell.')
->setHelp(
<<<'HELP'
Throws an exception or error out of the current the Psy Shell instance.
By default it throws the most recent exception.
e.g.
<return>>>> throw-up</return>
<return>>>> throw-up $e</return>
<return>>>> throw-up new Exception('WHEEEEEE!')</return>
<return>>>> throw-up "bye!"</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*
* @throws \InvalidArgumentException if there is no exception to throw
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$args = $this->prepareArgs($input->getArgument('exception'));
$throwStmt = new Expression(new Throw_(new New_(new FullyQualifiedName(ThrowUpException::class), $args)));
$throwCode = $this->printer->prettyPrint([$throwStmt]);
$shell = $this->getShell();
$shell->addCode($throwCode, !$shell->hasCode());
return 0;
}
/**
* Parse the supplied command argument.
*
* If no argument was given, this falls back to `$_e`
*
* @throws \InvalidArgumentException if there is no exception to throw
*
* @param string|null $code
*
* @return Arg[]
*/
private function prepareArgs(?string $code = null): array
{
if (!$code) {
// Default to last exception if nothing else was supplied
return [new Arg(new Variable('_e'))];
}
$nodes = $this->parser->parse($code);
if (\count($nodes) !== 1) {
throw new \InvalidArgumentException('No idea how to throw this');
}
$node = $nodes[0];
$expr = $node->expr;
$args = [new Arg($expr, false, false, $node->getAttributes())];
// Allow throwing via a string, e.g. `throw-up "SUP"`
if ($expr instanceof String_) {
return [new Arg(new New_(new FullyQualifiedName(\Exception::class), $args))];
}
return $args;
}
}

View File

@ -0,0 +1,175 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use PhpParser\NodeTraverser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\Command\TimeitCommand\TimeitVisitor;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Class TimeitCommand.
*/
class TimeitCommand extends Command
{
const RESULT_MSG = '<info>Command took %.6f seconds to complete.</info>';
const AVG_RESULT_MSG = '<info>Command took %.6f seconds on average (%.6f median; %.6f total) to complete.</info>';
// All times stored as nanoseconds!
private static ?int $start = null;
private static array $times = [];
private CodeArgumentParser $parser;
private NodeTraverser $traverser;
private Printer $printer;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->parser = new CodeArgumentParser();
// @todo Pass visitor directly to once we drop support for PHP-Parser 4.x
$this->traverser = new NodeTraverser();
$this->traverser->addVisitor(new TimeitVisitor());
$this->printer = new Printer();
parent::__construct($name);
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('timeit')
->setDefinition([
new InputOption('num', 'n', InputOption::VALUE_REQUIRED, 'Number of iterations.'),
new CodeArgument('code', CodeArgument::REQUIRED, 'Code to execute.'),
])
->setDescription('Profiles with a timer.')
->setHelp(
<<<'HELP'
Time profiling for functions and commands.
e.g.
<return>>>> timeit sleep(1)</return>
<return>>>> timeit -n1000 $closure()</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$code = $input->getArgument('code');
$num = (int) ($input->getOption('num') ?: 1);
$shell = $this->getShell();
$instrumentedCode = $this->instrumentCode($code);
self::$times = [];
do {
$_ = $shell->execute($instrumentedCode, true);
$this->ensureEndMarked();
} while (\count(self::$times) < $num);
$shell->writeReturnValue($_);
$times = self::$times;
self::$times = [];
if ($num === 1) {
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible (guaranteed by loop: count($times) >= $num)
$output->writeln(\sprintf(self::RESULT_MSG, $times[0] / 1e+9));
} else {
$total = \array_sum($times);
\rsort($times);
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible (guaranteed by loop: count($times) >= $num)
$median = $times[(int) \round($num / 2)];
$output->writeln(\sprintf(self::AVG_RESULT_MSG, ($total / $num) / 1e+9, $median / 1e+9, $total / 1e+9));
}
return 0;
}
/**
* Internal method for marking the start of timeit execution.
*
* A static call to this method will be injected at the start of the timeit
* input code to instrument the call. We will use the saved start time to
* more accurately calculate time elapsed during execution.
*/
public static function markStart()
{
self::$start = \hrtime(true);
}
/**
* Internal method for marking the end of timeit execution.
*
* A static call to this method is injected by TimeitVisitor at the end
* of the timeit input code to instrument the call.
*
* Note that this accepts an optional $ret parameter, which is used to pass
* the return value of the last statement back out of timeit. This saves us
* a bunch of code rewriting shenanigans.
*
* @param mixed $ret
*
* @return mixed it just passes $ret right back
*/
public static function markEnd($ret = null)
{
self::$times[] = \hrtime(true) - self::$start;
self::$start = null;
return $ret;
}
/**
* Ensure that the end of code execution was marked.
*
* The end *should* be marked in the instrumented code, but just in case
* we'll add a fallback here.
*/
private function ensureEndMarked()
{
if (self::$start !== null) {
self::markEnd();
}
}
/**
* Instrument code for timeit execution.
*
* This inserts `markStart` and `markEnd` calls to ensure that (reasonably)
* accurate times are recorded for just the code being executed.
*/
private function instrumentCode(string $code): string
{
return $this->printer->prettyPrint($this->traverser->traverse($this->parser->parse($code)));
}
}

View File

@ -0,0 +1,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\TimeitCommand;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\Return_;
use PhpParser\NodeVisitorAbstract;
use Psy\CodeCleaner\NoReturnValue;
use Psy\Command\TimeitCommand;
/**
* A node visitor for instrumenting code to be executed by the `timeit` command.
*
* Injects `TimeitCommand::markStart()` at the start of code to be executed, and
* `TimeitCommand::markEnd()` at the end, and on top-level return statements.
*/
class TimeitVisitor extends NodeVisitorAbstract
{
private int $functionDepth = 0;
/**
* {@inheritdoc}
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->functionDepth = 0;
return null;
}
/**
* {@inheritdoc}
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
// keep track of nested function-like nodes, because they can have
// returns statements... and we don't want to call markEnd for those.
if ($node instanceof FunctionLike) {
$this->functionDepth++;
return null;
}
// replace any top-level `return` statements with a `markEnd` call
if ($this->functionDepth === 0 && $node instanceof Return_) {
return new Return_($this->getEndCall($node->expr), $node->getAttributes());
}
return null;
}
/**
* {@inheritdoc}
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth--;
}
return null;
}
/**
* {@inheritdoc}
*
* @return Node[]|null Array of nodes
*/
public function afterTraverse(array $nodes)
{
// prepend a `markStart` call
\array_unshift($nodes, new Expression($this->getStartCall(), []));
// append a `markEnd` call (wrapping the final node, if it's an expression)
$last = $nodes[\count($nodes) - 1];
if ($last instanceof Expr) {
\array_pop($nodes);
$nodes[] = $this->getEndCall($last);
} elseif ($last instanceof Expression) {
\array_pop($nodes);
$nodes[] = new Expression($this->getEndCall($last->expr), $last->getAttributes());
} elseif ($last instanceof Return_) {
// nothing to do here, we're already ending with a return call
} else {
$nodes[] = new Expression($this->getEndCall(), []);
}
return $nodes;
}
/**
* Get PhpParser AST nodes for a `markStart` call.
*
* @return \PhpParser\Node\Expr\StaticCall
*/
private function getStartCall(): StaticCall
{
return new StaticCall(new FullyQualifiedName(TimeitCommand::class), 'markStart');
}
/**
* Get PhpParser AST nodes for a `markEnd` call.
*
* Optionally pass in a return value.
*
* @param Expr|null $arg
*/
private function getEndCall(?Expr $arg = null): StaticCall
{
if ($arg === null) {
$arg = NoReturnValue::create();
}
return new StaticCall(new FullyQualifiedName(TimeitCommand::class), 'markEnd', [new Arg($arg)]);
}
}

View File

@ -0,0 +1,99 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Formatter\TraceFormatter;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Show the current stack trace.
*/
class TraceCommand extends Command
{
protected $filter;
/**
* {@inheritdoc}
*/
public function __construct($name = null)
{
$this->filter = new FilterOptions();
parent::__construct($name);
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
list($grep, $insensitive, $invert) = FilterOptions::getOptions();
$this
->setName('trace')
->setDefinition([
new InputOption('include-psy', 'p', InputOption::VALUE_NONE, 'Include Psy in the call stack.'),
new InputOption('num', 'n', InputOption::VALUE_REQUIRED, 'Only include NUM lines.'),
$grep,
$insensitive,
$invert,
])
->setDescription('Show the current call stack.')
->setHelp(
<<<'HELP'
Show the current call stack.
Optionally, include PsySH in the call stack by passing the <info>--include-psy</info> option.
e.g.
<return>> trace -n10</return>
<return>> trace --include-psy</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->filter->bind($input);
$trace = $this->getBacktrace(new \Exception(), $input->getOption('num'), $input->getOption('include-psy'));
$output->page($trace, ShellOutput::NUMBER_LINES);
return 0;
}
/**
* Get a backtrace for an exception or error.
*
* Optionally limit the number of rows to include with $count, and exclude
* Psy from the trace.
*
* @param \Throwable $e The exception or error with a backtrace
* @param int|null $count (default: PHP_INT_MAX)
* @param bool $includePsy (default: true)
*
* @return array Formatted stacktrace lines
*/
protected function getBacktrace(\Throwable $e, ?int $count = null, bool $includePsy = true): array
{
return TraceFormatter::formatTrace($e, $this->filter, $count, $includePsy);
}
}

View File

@ -0,0 +1,140 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\ConfigPaths;
use Psy\Formatter\CodeFormatter;
use Psy\Output\ShellOutput;
use Psy\Shell;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Show the context of where you opened the debugger.
*/
class WhereamiCommand extends Command
{
private array $backtrace;
public function __construct()
{
$this->backtrace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('whereami')
->setDefinition([
new InputOption('num', 'n', InputOption::VALUE_OPTIONAL, 'Number of lines before and after.', '5'),
new InputOption('file', 'f|a', InputOption::VALUE_NONE, 'Show the full source for the current file.'),
])
->setDescription('Show where you are in the code.')
->setHelp(
<<<'HELP'
Show where you are in the code.
Optionally, include the number of lines before and after you want to display,
or --file for the whole file.
e.g.
<return>> whereami </return>
<return>> whereami -n10</return>
<return>> whereami --file</return>
HELP
);
}
/**
* Obtains the correct stack frame in the full backtrace.
*
* @return array
*/
protected function trace(): array
{
foreach (\array_reverse($this->backtrace) as $stackFrame) {
if ($this->isDebugCall($stackFrame)) {
return $stackFrame;
}
}
return \end($this->backtrace);
}
private static function isDebugCall(array $stackFrame): bool
{
$class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
$function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
return ($class === null && $function === 'Psy\\debug') ||
($class === Shell::class && \in_array($function, ['__construct', 'debug']));
}
/**
* Determine the file and line based on the specific backtrace.
*
* @return array
*/
protected function fileInfo(): array
{
$stackFrame = $this->trace();
if (\preg_match('/eval\(/', $stackFrame['file'])) {
\preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
$file = $matches[1][0];
$line = (int) $matches[2][0];
} else {
$file = $stackFrame['file'];
$line = $stackFrame['line'];
}
return \compact('file', 'line');
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$info = $this->fileInfo();
$num = $input->getOption('num');
$lineNum = $info['line'];
$startLine = \max($lineNum - $num, 1);
$endLine = $lineNum + $num;
$code = \file_get_contents($info['file']);
if ($input->getOption('file')) {
$startLine = 1;
$endLine = null;
}
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$output->writeln(\sprintf('From <info>%s:%s</info>:', ConfigPaths::prettyPath($info['file']), $lineNum));
$output->write(CodeFormatter::formatCode($code, $startLine, $endLine, $lineNum), false);
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
return 0;
}
}

View File

@ -0,0 +1,129 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Context;
use Psy\ContextAware;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Show the last uncaught exception.
*/
class WtfCommand extends TraceCommand implements ContextAware
{
protected Context $context;
/**
* ContextAware interface.
*
* @param Context $context
*/
public function setContext(Context $context)
{
$this->context = $context;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
list($grep, $insensitive, $invert) = FilterOptions::getOptions();
$this
->setName('wtf')
->setAliases(['last-exception', 'wtf?'])
->setDefinition([
new InputArgument('incredulity', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Number of lines to show.'),
new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show entire backtrace.'),
$grep,
$insensitive,
$invert,
])
->setDescription('Show the backtrace of the most recent exception.')
->setHelp(
<<<'HELP'
Shows a few lines of the backtrace of the most recent exception.
If you want to see more lines, add more question marks or exclamation marks:
e.g.
<return>>>> wtf ?</return>
<return>>>> wtf ?!???!?!?</return>
To see the entire backtrace, pass the -a/--all flag:
e.g.
<return>>>> wtf -a</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->filter->bind($input);
$incredulity = \implode('', $input->getArgument('incredulity'));
if (\strlen(\preg_replace('/[\\?!]/', '', $incredulity))) {
throw new \InvalidArgumentException('Incredulity must include only "?" and "!"');
}
$exception = $this->context->getLastException();
$count = $input->getOption('all') ? \PHP_INT_MAX : \max(3, \pow(2, \strlen($incredulity) + 1));
if ($output instanceof ShellOutput) {
$output->startPaging();
}
do {
$traceCount = \count($exception->getTrace());
$showLines = $count;
// Show the whole trace if we'd only be hiding a few lines
if ($traceCount < \max($count * 1.2, $count + 2)) {
$showLines = \PHP_INT_MAX;
}
$trace = $this->getBacktrace($exception, $showLines);
$moreLines = $traceCount - \count($trace);
$output->writeln($this->getShell()->formatException($exception));
$output->writeln('--');
$output->write($trace, true, ShellOutput::NUMBER_LINES);
$output->writeln('');
if ($moreLines > 0) {
$output->writeln(\sprintf(
'<aside>Use <return>wtf -a</return> to see %d more lines</aside>',
$moreLines
));
$output->writeln('');
}
} while ($exception = $exception->getPrevious());
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
return 0;
}
}

View File

@ -0,0 +1,91 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command;
use Psy\Input\CodeArgument;
use Psy\Readline\Readline;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Execute code while bypassing reloader safety checks.
*/
class YoloCommand extends Command
{
private Readline $readline;
/**
* Set the Shell's Readline service.
*/
public function setReadline(Readline $readline)
{
$this->readline = $readline;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('yolo')
->setDefinition([
new CodeArgument('code', CodeArgument::REQUIRED, 'Code to execute, or !! to repeat last.'),
])
->setDescription('Execute code while bypassing reloader safety checks.')
->setHelp(
<<<'HELP'
Execute code with all reloader safety checks bypassed.
When the reloader shows warnings about skipped conditionals or other
risky operations, use yolo to force reload and execute anyway:
e.g.
<return>>>> my_helper()</return>
<return>Warning: Skipped conditional: if (...) { function my_helper() ... }</return>
<return>>>> yolo !!</return>
<return>=> "result"</return>
HELP
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$code = $input->getArgument('code');
// Handle !! for last command
if ($code === '!!') {
$history = $this->readline->listHistory();
\array_pop($history); // Remove the current `yolo !!` invocation
$code = \end($history) ?: '';
if (empty($code)) {
throw new \RuntimeException('No previous command to repeat');
}
}
$shell = $this->getShell();
$shell->setForceReload(true);
try {
$shell->addCode($code);
return 0;
} finally {
$shell->setForceReload(false);
}
}
}

446
vendor/psy/psysh/src/ConfigPaths.php vendored Normal file
View File

@ -0,0 +1,446 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* A Psy Shell configuration path helper.
*/
class ConfigPaths
{
private ?string $configDir = null;
private ?string $dataDir = null;
private ?string $runtimeDir = null;
private EnvInterface $env;
/**
* ConfigPaths constructor.
*
* Optionally provide `configDir`, `dataDir` and `runtimeDir` overrides.
*
* @see self::overrideDirs
*
* @param string[] $overrides Directory overrides
* @param EnvInterface|null $env
*/
public function __construct(array $overrides = [], ?EnvInterface $env = null)
{
$this->overrideDirs($overrides);
$this->env = $env ?: (\PHP_SAPI === 'cli-server' ? new SystemEnv() : new SuperglobalsEnv());
}
/**
* Provide `configDir`, `dataDir` and `runtimeDir` overrides.
*
* If a key is set but empty, the override will be removed. If it is not set
* at all, any existing override will persist.
*
* @param string[] $overrides Directory overrides
*/
public function overrideDirs(array $overrides)
{
if (\array_key_exists('configDir', $overrides)) {
$this->configDir = $overrides['configDir'] ?: null;
}
if (\array_key_exists('dataDir', $overrides)) {
$this->dataDir = $overrides['dataDir'] ?: null;
}
if (\array_key_exists('runtimeDir', $overrides)) {
$this->runtimeDir = $overrides['runtimeDir'] ?: null;
}
}
/**
* Get the current home directory.
*/
public function homeDir(): ?string
{
if ($homeDir = $this->getEnv('HOME') ?: $this->windowsHomeDir()) {
return \strtr($homeDir, '\\', '/');
}
return null;
}
private function windowsHomeDir(): ?string
{
if (\defined('PHP_WINDOWS_VERSION_MAJOR')) {
$homeDrive = $this->getEnv('HOMEDRIVE');
$homePath = $this->getEnv('HOMEPATH');
if ($homeDrive && $homePath) {
return $homeDrive.'/'.$homePath;
}
}
return null;
}
private function homeConfigDir(): ?string
{
if ($homeConfigDir = $this->getEnv('XDG_CONFIG_HOME')) {
return $homeConfigDir;
}
$homeDir = $this->homeDir();
if ($homeDir === null) {
return null;
}
return $homeDir === '/' ? $homeDir.'.config' : $homeDir.'/.config';
}
/**
* Get potential config directory paths.
*
* Returns `~/.psysh`, `%APPDATA%/PsySH` (when on Windows), and all
* XDG Base Directory config directories:
*
* http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
*
* @return string[]
*/
public function configDirs(): array
{
if ($this->configDir !== null) {
return [$this->configDir];
}
$configDirs = $this->getEnvArray('XDG_CONFIG_DIRS') ?: ['/etc/xdg'];
return $this->allDirNames(\array_merge([$this->homeConfigDir()], $configDirs));
}
/**
* Get the current home config directory.
*
* Returns the highest precedence home config directory which actually
* exists. If none of them exists, returns the highest precedence home
* config directory (`%APPDATA%/PsySH` on Windows, `~/.config/psysh`
* everywhere else).
*
* @see self::homeConfigDir
*/
public function currentConfigDir(): ?string
{
if ($this->configDir !== null) {
return $this->configDir;
}
$configDirs = $this->allDirNames([$this->homeConfigDir()]);
foreach ($configDirs as $configDir) {
if (@\is_dir($configDir)) {
return $configDir;
}
}
return $configDirs[0] ?? null;
}
/**
* Find real config files in config directories.
*
* @param string[] $names Config file names
*
* @return string[]
*/
public function configFiles(array $names): array
{
return $this->allRealFiles($this->configDirs(), $names);
}
/**
* Get potential data directory paths.
*
* If a `dataDir` option was explicitly set, returns an array containing
* just that directory.
*
* Otherwise, it returns `~/.psysh` and all XDG Base Directory data directories:
*
* http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
*
* @return string[]
*/
public function dataDirs(): array
{
if ($this->dataDir !== null) {
return [$this->dataDir];
}
$homeDataDir = $this->getEnv('XDG_DATA_HOME') ?: $this->homeDir().'/.local/share';
$dataDirs = $this->getEnvArray('XDG_DATA_DIRS') ?: ['/usr/local/share', '/usr/share'];
return $this->allDirNames(\array_merge([$homeDataDir], $dataDirs));
}
/**
* Get the current home data directory.
*
* Returns the highest precedence home data directory which actually
* exists and is writable. If none of them exists, returns the highest
* precedence home data directory.
*/
public function currentDataDir(): ?string
{
if ($this->dataDir !== null) {
return $this->dataDir;
}
$dataDirs = $this->dataDirs();
// Find first writable directory
foreach ($dataDirs as $dir) {
if (@\is_dir($dir) && @\is_writable($dir)) {
return $dir;
}
}
// Return first (user) directory even if it doesn't exist yet
return $dataDirs[0] ?? null;
}
/**
* Find real data files in config directories.
*
* @param string[] $names Config file names
*
* @return string[]
*/
public function dataFiles(array $names): array
{
return $this->allRealFiles($this->dataDirs(), $names);
}
/**
* Get a runtime directory.
*
* Defaults to `/psysh` inside the system's temp dir.
*/
public function runtimeDir(): string
{
if ($this->runtimeDir !== null) {
return $this->runtimeDir;
}
// Fallback to a boring old folder in the system temp dir.
$runtimeDir = $this->getEnv('XDG_RUNTIME_DIR') ?: \sys_get_temp_dir();
return \strtr($runtimeDir, '\\', '/').'/psysh';
}
/**
* Get a list of directories in PATH.
*
* If $PATH is unset/empty it defaults to '/usr/sbin:/usr/bin:/sbin:/bin'.
*
* @return string[]
*/
public function pathDirs(): array
{
return $this->getEnvArray('PATH') ?: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'];
}
/**
* Locate a command (an executable) in $PATH.
*
* Behaves like 'command -v COMMAND' or 'which COMMAND'.
* If $PATH is unset/empty it defaults to '/usr/sbin:/usr/bin:/sbin:/bin'.
*
* @param string $command the executable to locate
*/
public function which($command): ?string
{
if (!\is_string($command) || $command === '') {
return null;
}
foreach ($this->pathDirs() as $path) {
$fullpath = $path.\DIRECTORY_SEPARATOR.$command;
if (@\is_file($fullpath) && @\is_executable($fullpath)) {
return $fullpath;
}
}
return null;
}
/**
* Get all PsySH directory name candidates given a list of base directories.
*
* This expects that XDG-compatible directory paths will be passed in.
* `psysh` will be added to each of $baseDirs, and we'll throw in `~/.psysh`
* and a couple of Windows-friendly paths as well.
*
* @param string[] $baseDirs base directory paths
*
* @return string[]
*/
private function allDirNames(array $baseDirs): array
{
$baseDirs = \array_filter($baseDirs);
$dirs = \array_map(function ($dir) {
return \strtr($dir, '\\', '/').'/psysh';
}, $baseDirs);
// Add ~/.psysh
if ($home = $this->getEnv('HOME')) {
$dirs[] = \strtr($home, '\\', '/').'/.psysh';
}
// Add some Windows specific ones :)
if (\defined('PHP_WINDOWS_VERSION_MAJOR')) {
if ($appData = $this->getEnv('APPDATA')) {
// AppData gets preference
\array_unshift($dirs, \strtr($appData, '\\', '/').'/PsySH');
}
if ($windowsHomeDir = $this->windowsHomeDir()) {
$dir = \strtr($windowsHomeDir, '\\', '/').'/.psysh';
if (!\in_array($dir, $dirs)) {
$dirs[] = $dir;
}
}
}
return $dirs;
}
/**
* Given a list of directories, and a list of filenames, find the ones that
* are real files.
*
* @return string[]
*/
private function allRealFiles(array $dirNames, array $fileNames): array
{
$files = [];
foreach ($dirNames as $dir) {
foreach ($fileNames as $name) {
$file = $dir.'/'.$name;
if (@\is_file($file)) {
$files[] = $file;
}
}
}
return $files;
}
/**
* Make a path prettier by replacing cwd with . or home directory with ~.
*
* @param string|mixed $path Path to prettify
* @param string|null $relativeTo Directory to make path relative to (defaults to cwd)
* @param string|null $homeDir Home directory to replace with ~ (defaults to actual home)
*
* @return string|mixed Pretty path, or original value if not a string
*/
public static function prettyPath($path, ?string $relativeTo = null, ?string $homeDir = null)
{
if (!\is_string($path)) {
return $path;
}
$path = \strtr($path, '\\', '/');
// Try replacing relativeTo directory first (more specific)
$relativeTo = $relativeTo ?: \getcwd();
if ($relativeTo !== false) {
$relativeTo = \rtrim(\strtr($relativeTo, '\\', '/'), '/').'/';
if (\strpos($path, $relativeTo) === 0) {
return './'.\substr($path, \strlen($relativeTo));
}
}
// Fall back to replacing home directory
$homeDir = $homeDir ?: (new self())->homeDir();
if ($homeDir && $homeDir !== '/') {
$homeDir = \rtrim(\strtr($homeDir, '\\', '/'), '/').'/';
if (\strpos($path, $homeDir) === 0) {
return '~/'.\substr($path, \strlen($homeDir));
}
}
return $path;
}
/**
* Ensure that $dir exists and is writable.
*
* Generates E_USER_NOTICE error if the directory is not writable or creatable.
*
* @param string $dir
*
* @return bool False if directory exists but is not writeable, or cannot be created
*/
public static function ensureDir(string $dir): bool
{
if (!\is_dir($dir)) {
// Just try making it and see if it works
@\mkdir($dir, 0700, true);
}
if (!\is_dir($dir) || !\is_writable($dir)) {
\trigger_error(\sprintf('Writing to directory %s is not allowed.', $dir), \E_USER_NOTICE);
return false;
}
return true;
}
/**
* Ensure that $file exists and is writable, make the parent directory if necessary.
*
* Generates E_USER_NOTICE error if either $file or its directory is not writable.
*
* @param string $file
*
* @return string|false Full path to $file, or false if file is not writable
*/
public static function touchFileWithMkdir(string $file)
{
if (\file_exists($file)) {
if (\is_writable($file)) {
return $file;
}
\trigger_error(\sprintf('Writing to %s is not allowed.', $file), \E_USER_NOTICE);
return false;
}
if (!self::ensureDir(\dirname($file))) {
return false;
}
\touch($file);
return $file;
}
private function getEnv(string $key)
{
return $this->env->get($key);
}
private function getEnvArray(string $key)
{
if ($value = $this->getEnv($key)) {
return \explode(\PATH_SEPARATOR, $value);
}
return null;
}
}

2545
vendor/psy/psysh/src/Configuration.php vendored Normal file

File diff suppressed because it is too large Load Diff

303
vendor/psy/psysh/src/Context.php vendored Normal file
View File

@ -0,0 +1,303 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* The Shell execution context.
*
* This class encapsulates the current variables, most recent return value and
* exception, and the current namespace.
*/
class Context
{
private const SPECIAL_NAMES = ['_', '_e', '__out', '__psysh__', 'this'];
// Include a very limited number of command-scope magic variable names.
// This might be a bad idea, but future me can sort it out.
private const COMMAND_SCOPE_NAMES = [
'__function', '__method', '__class', '__namespace', '__file', '__line', '__dir',
];
private array $scopeVariables = [];
private array $commandScopeVariables = [];
/** @var mixed */
private $returnValue = null;
private ?\Throwable $lastException = null;
private ?string $lastStdout = null;
private ?object $boundObject = null;
private ?string $boundClass = null;
/**
* Get a context variable.
*
* @throws \InvalidArgumentException If the variable is not found in the current context
*
* @return mixed
*/
public function get(string $name)
{
switch ($name) {
case '_':
return $this->returnValue;
case '_e':
if (isset($this->lastException)) {
return $this->lastException;
}
break;
case '__out':
if (isset($this->lastStdout)) {
return $this->lastStdout;
}
break;
case 'this':
if (isset($this->boundObject)) {
return $this->boundObject;
}
break;
case '__function':
case '__method':
case '__class':
case '__namespace':
case '__file':
case '__line':
case '__dir':
if (\array_key_exists($name, $this->commandScopeVariables)) {
return $this->commandScopeVariables[$name];
}
break;
default:
if (\array_key_exists($name, $this->scopeVariables)) {
return $this->scopeVariables[$name];
}
break;
}
throw new \InvalidArgumentException('Unknown variable: $'.$name);
}
/**
* Get all defined variables.
*/
public function getAll(): array
{
return \array_merge($this->scopeVariables, $this->getSpecialVariables());
}
/**
* Get all defined magic variables: $_, $_e, $__out, $__class, $__file, etc.
*/
public function getSpecialVariables(): array
{
$vars = [
'_' => $this->returnValue,
];
if (isset($this->lastException)) {
$vars['_e'] = $this->lastException;
}
if (isset($this->lastStdout)) {
$vars['__out'] = $this->lastStdout;
}
if (isset($this->boundObject)) {
$vars['this'] = $this->boundObject;
}
return \array_merge($vars, $this->commandScopeVariables);
}
/**
* Set all scope variables.
*
* This method does *not* set any of the magic variables: $_, $_e, $__out,
* $__class, $__file, etc.
*/
public function setAll(array $vars)
{
foreach (self::SPECIAL_NAMES as $key) {
unset($vars[$key]);
}
foreach (self::COMMAND_SCOPE_NAMES as $key) {
unset($vars[$key]);
}
$this->scopeVariables = $vars;
}
/**
* Set the most recent return value.
*
* @param mixed $value
*/
public function setReturnValue($value)
{
$this->returnValue = $value;
}
/**
* Get the most recent return value.
*
* @return mixed
*/
public function getReturnValue()
{
return $this->returnValue;
}
/**
* Set the most recent Exception or Error.
*
* @param \Throwable $e
*/
public function setLastException(\Throwable $e)
{
$this->lastException = $e;
}
/**
* Get the most recent Exception or Error.
*
* @throws \InvalidArgumentException If no Exception has been caught
*
* @return \Throwable|null
*/
public function getLastException()
{
if (!isset($this->lastException)) {
throw new \InvalidArgumentException('No most-recent exception');
}
return $this->lastException;
}
/**
* Set the most recent output from evaluated code.
*/
public function setLastStdout(string $lastStdout)
{
$this->lastStdout = $lastStdout;
}
/**
* Get the most recent output from evaluated code.
*
* @throws \InvalidArgumentException If no output has happened yet
*
* @return string|null
*/
public function getLastStdout()
{
if (!isset($this->lastStdout)) {
throw new \InvalidArgumentException('No most-recent output');
}
return $this->lastStdout;
}
/**
* Set the bound object ($this variable) for the interactive shell.
*
* Note that this unsets the bound class, if any exists.
*
* @param object|null $boundObject
*/
public function setBoundObject($boundObject)
{
$this->boundObject = \is_object($boundObject) ? $boundObject : null;
$this->boundClass = null;
}
/**
* Get the bound object ($this variable) for the interactive shell.
*
* @return object|null
*/
public function getBoundObject()
{
return $this->boundObject;
}
/**
* Set the bound class (self) for the interactive shell.
*
* Note that this unsets the bound object, if any exists.
*
* @param string|null $boundClass
*/
public function setBoundClass($boundClass)
{
$this->boundClass = (\is_string($boundClass) && $boundClass !== '') ? $boundClass : null;
$this->boundObject = null;
}
/**
* Get the bound class (self) for the interactive shell.
*
* @return string|null
*/
public function getBoundClass()
{
return $this->boundClass;
}
/**
* Set command-scope magic variables: $__class, $__file, etc.
*/
public function setCommandScopeVariables(array $commandScopeVariables)
{
$vars = [];
foreach ($commandScopeVariables as $key => $value) {
// kind of type check
if (\is_scalar($value) && \in_array($key, self::COMMAND_SCOPE_NAMES)) {
$vars[$key] = $value;
}
}
$this->commandScopeVariables = $vars;
}
/**
* Get command-scope magic variables: $__class, $__file, etc.
*/
public function getCommandScopeVariables(): array
{
return $this->commandScopeVariables;
}
/**
* Get unused command-scope magic variables names: __class, __file, etc.
*
* This is used by the shell to unset old command-scope variables after a
* new batch is set.
*
* @return array Array of unused variable names
*/
public function getUnusedCommandScopeVariableNames(): array
{
return \array_diff(self::COMMAND_SCOPE_NAMES, \array_keys($this->commandScopeVariables));
}
/**
* Check whether a variable name is a magic variable.
*/
public static function isSpecialVariableName(string $name): bool
{
return \in_array($name, self::SPECIAL_NAMES) || \in_array($name, self::COMMAND_SCOPE_NAMES);
}
}

28
vendor/psy/psysh/src/ContextAware.php vendored Normal file
View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* ContextAware interface.
*
* This interface is used to pass the Shell's context into commands and such
* which require access to the current scope variables.
*/
interface ContextAware
{
/**
* Set the Context reference.
*
* @param Context $context
*/
public function setContext(Context $context);
}

25
vendor/psy/psysh/src/EnvInterface.php vendored Normal file
View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* Abstraction around environment variables.
*/
interface EnvInterface
{
/**
* Get an environment variable by name.
*
* @return string|null
*/
public function get(string $key);
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A break exception, used for halting the Psy Shell.
*/
class BreakException extends \Exception implements Exception
{
private string $rawMessage;
/**
* {@inheritdoc}
*/
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
{
$this->rawMessage = $message;
parent::__construct(\sprintf('Exit: %s', $message), $code, $previous);
}
/**
* Return a raw (unformatted) version of the error message.
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
/**
* Throws BreakException.
*
* Since `throw` can not be inserted into arbitrary expressions, it wraps with function call.
*
* @param int|string|null $status Exit status code or message
*
* @throws BreakException
*/
public static function exitShell($status = 0)
{
throw new self(\is_string($status) ? $status : 'Goodbye', \is_int($status) ? $status : 0);
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A DeprecatedException for Psy.
*/
class DeprecatedException extends RuntimeException
{
// This space intentionally left blank.
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A custom error Exception for Psy with a formatted $message.
*/
class ErrorException extends \ErrorException implements Exception
{
private string $rawMessage;
/**
* Construct a Psy ErrorException.
*
* @param string $message (default: "")
* @param int $code (default: 0)
* @param int $severity (default: 1)
* @param string|null $filename (default: null)
* @param int|null $lineno (default: null)
* @param \Throwable|null $previous (default: null)
*/
public function __construct($message = '', $code = 0, $severity = 1, $filename = null, $lineno = null, ?\Throwable $previous = null)
{
$this->rawMessage = $message;
if (!empty($filename) && \preg_match('{Psy[/\\\\]ExecutionLoop}', $filename)) {
$filename = '';
}
switch ($severity) {
case \E_NOTICE:
case \E_USER_NOTICE:
$type = 'Notice';
break;
case \E_WARNING:
case \E_CORE_WARNING:
case \E_COMPILE_WARNING:
case \E_USER_WARNING:
$type = 'Warning';
break;
case \E_DEPRECATED:
case \E_USER_DEPRECATED:
$type = 'Deprecated';
break;
case \E_RECOVERABLE_ERROR:
$type = 'Recoverable fatal error';
break;
default:
if (\PHP_VERSION_ID < 80400 && $severity === \E_STRICT) {
$type = 'Strict error';
break;
}
$type = 'Error';
break;
}
$message = \sprintf('PHP %s: %s%s on line %d', $type, $message, $filename ? ' in '.$filename : '', $lineno ?? 0);
parent::__construct($message, $code, $severity, $filename ?? '', $lineno ?? 0, $previous);
}
/**
* Get the raw (unformatted) message for this error.
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
/**
* Helper for throwing an ErrorException.
*
* This allows us to:
*
* set_error_handler([ErrorException::class, 'throwException']);
*
* @throws self
*
* @param int $errno Error type
* @param string $errstr Message
* @param string $errfile Filename
* @param int $errline Line number
*/
public static function throwException($errno, $errstr, $errfile, $errline)
{
throw new self($errstr, 0, $errno, $errfile, $errline);
}
/**
* Create an ErrorException from an Error.
*
* @deprecated PsySH no longer wraps Errors
*
* @param \Error $e
*/
public static function fromError(\Error $e)
{
@\trigger_error('PsySH no longer wraps Errors', \E_USER_DEPRECATED);
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* An interface for Psy Exceptions.
*/
interface Exception
{
/**
* This is the only thing, really...
*
* Return a raw (unformatted) version of the message.
*
* @return string
*/
public function getRawMessage();
}

View File

@ -0,0 +1,50 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A "fatal error" Exception for Psy.
*/
class FatalErrorException extends \ErrorException implements Exception
{
private string $rawMessage;
/**
* Create a fatal error.
*
* @param string $message (default: "")
* @param int $code (default: 0)
* @param int $severity (default: 1)
* @param string|null $filename (default: null)
* @param int|null $lineno (default: null)
* @param \Throwable|null $previous (default: null)
*/
public function __construct($message = '', $code = 0, $severity = 1, $filename = null, $lineno = null, ?\Throwable $previous = null)
{
// Since these are basically always PHP Parser Node line numbers, treat -1 as null.
if ($lineno === -1) {
$lineno = null;
}
$this->rawMessage = $message;
$message = \sprintf('PHP Fatal error: %s in %s on line %d', $message, $filename ?: "eval()'d code", $lineno ?? 0);
parent::__construct($message, $code, $severity, $filename ?? '', $lineno ?? 0, $previous);
}
/**
* Return a raw (unformatted) version of the error message.
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
}

View File

@ -0,0 +1,29 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* An interrupt exception, used when Ctrl-C interrupts running code.
*
* Unlike BreakException, this does not exit the REPL, it only cancels
* the current execution and returns to the prompt.
*/
class InterruptException extends \Exception implements Exception
{
/**
* Return a raw (unformatted) version of the error message.
*/
public function getRawMessage(): string
{
return $this->getMessage();
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* An exception for invalid manual files.
*/
class InvalidManualException extends RuntimeException
{
private string $manualFile;
/**
* @param string $message Error message
* @param string $manualFile Path to the invalid manual file
* @param int $code (default: 0)
* @param \Throwable|null $previous (default: null)
*/
public function __construct(string $message, string $manualFile, int $code = 0, ?\Throwable $previous = null)
{
$this->manualFile = $manualFile;
parent::__construct($message, $code, $previous);
}
/**
* Get the path to the invalid manual file.
*/
public function getManualFile(): string
{
return $this->manualFile;
}
}

View File

@ -0,0 +1,46 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A "parse error" Exception for Psy.
*/
class ParseErrorException extends \PhpParser\Error implements Exception
{
/**
* Constructor!
*
* @param string $message (default: '')
* @param array|int $attributes Attributes of node/token where error occurred
* (or start line of error -- deprecated)
*/
public function __construct(string $message = '', $attributes = [])
{
$message = \sprintf('PHP Parse error: %s', $message);
if (!\is_array($attributes)) {
$attributes = ['startLine' => $attributes];
}
parent::__construct($message, $attributes);
}
/**
* Create a ParseErrorException from a PhpParser Error.
*
* @param \PhpParser\Error $e
*/
public static function fromParseError(\PhpParser\Error $e): self
{
return new self($e->getRawMessage(), $e->getAttributes());
}
}

View File

@ -0,0 +1,41 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A RuntimeException for Psy.
*/
class RuntimeException extends \RuntimeException implements Exception
{
private string $rawMessage;
/**
* Make this bad boy.
*
* @param string $message (default: "")
* @param int $code (default: 0)
* @param \Throwable|null $previous (default: null)
*/
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null)
{
$this->rawMessage = $message;
parent::__construct($message, $code, $previous);
}
/**
* Return a raw (unformatted) version of the error message.
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
}

View File

@ -0,0 +1,47 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
/**
* A throw-up exception, used for throwing an exception out of the Psy Shell.
*/
class ThrowUpException extends \Exception implements Exception
{
/**
* {@inheritdoc}
*/
public function __construct(\Throwable $throwable)
{
$message = \sprintf("Throwing %s with message '%s'", \get_class($throwable), $throwable->getMessage());
parent::__construct($message, $throwable->getCode(), $throwable);
}
/**
* Return a raw (unformatted) version of the error message.
*/
public function getRawMessage(): string
{
return $this->getPrevious()->getMessage();
}
/**
* Create a ThrowUpException from a Throwable.
*
* @deprecated PsySH no longer wraps Throwables
*
* @param \Throwable $throwable
*/
public static function fromThrowable($throwable)
{
@\trigger_error('PsySH no longer wraps Throwables', \E_USER_DEPRECATED);
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Exception;
class UnexpectedTargetException extends RuntimeException
{
/** @var mixed */
private $target;
/**
* @param mixed $target
* @param string $message (default: "")
* @param int $code (default: 0)
* @param \Throwable|null $previous (default: null)
*/
public function __construct($target, string $message = '', int $code = 0, ?\Throwable $previous = null)
{
$this->target = $target;
parent::__construct($message, $code, $previous);
}
/**
* @return mixed
*/
public function getTarget()
{
return $this->target;
}
}

View File

@ -0,0 +1,92 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
/**
* The Psy Shell's execution scope.
*/
class ExecutionClosure
{
const NOOP_INPUT = 'return null;';
private \Closure $closure;
/**
* @param Shell $__psysh__
*/
public function __construct(Shell $__psysh__)
{
$this->setClosure($__psysh__, function () use ($__psysh__) {
try {
// Restore execution scope variables
// @phan-suppress-next-line PhanTypeNonVarPassByRef assigning to a temp variable pollutes scope
\extract($__psysh__->getScopeVariables(false));
// Buffer stdout; we'll need it later
\ob_start([$__psysh__, 'writeStdout'], 1);
// Convert all errors to exceptions
\set_error_handler([$__psysh__, 'handleError']);
// Evaluate the current code buffer
$_ = eval($__psysh__->onExecute($__psysh__->flushCode() ?: self::NOOP_INPUT));
} catch (\Throwable $_e) {
// Clean up on our way out.
if (\ob_get_level() > 0) {
\ob_end_clean();
}
throw $_e;
} finally {
// Won't be needing this anymore
\restore_error_handler();
}
// Flush stdout (write to shell output, plus save to magic variable)
\ob_end_flush();
// Save execution scope variables for next time
$__psysh__->setScopeVariables(\get_defined_vars());
return $_;
});
}
/**
* Set the closure instance.
*
* @param Shell $shell
* @param \Closure $closure
*/
protected function setClosure(Shell $shell, \Closure $closure)
{
$that = $shell->getBoundObject();
if (\is_object($that)) {
$this->closure = $closure->bindTo($that, \get_class($that));
} else {
$this->closure = $closure->bindTo(null, $shell->getBoundClass());
}
}
/**
* Go go gadget closure.
*
* @return mixed
*/
public function execute()
{
$closure = $this->closure;
return $closure();
}
}

View File

@ -0,0 +1,64 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Shell;
/**
* Abstract Execution Loop Listener class.
*/
abstract class AbstractListener implements Listener
{
/**
* {@inheritdoc}
*/
public function beforeRun(Shell $shell)
{
}
/**
* {@inheritdoc}
*/
public function beforeLoop(Shell $shell)
{
}
/**
* {@inheritdoc}
*/
public function onInput(Shell $shell, string $input)
{
return null;
}
/**
* {@inheritdoc}
*/
public function onExecute(Shell $shell, string $code)
{
return null;
}
/**
* {@inheritdoc}
*/
public function afterLoop(Shell $shell)
{
}
/**
* {@inheritdoc}
*/
public function afterRun(Shell $shell, int $exitCode = 0)
{
}
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Shell;
use Psy\ShellLogger;
/**
* Execution logging listener.
*
* Logs code about to be executed to a ShellLogger.
*/
class ExecutionLoggingListener extends AbstractListener
{
private ShellLogger $logger;
/**
* @param ShellLogger $logger
*/
public function __construct(ShellLogger $logger)
{
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public static function isSupported(): bool
{
return true;
}
/**
* {@inheritdoc}
*/
public function onExecute(Shell $shell, string $code)
{
$this->logger->logExecute($code);
return null;
}
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Shell;
use Psy\ShellLogger;
/**
* Input logging listener.
*
* Logs user code input to a ShellLogger.
*/
class InputLoggingListener extends AbstractListener
{
private ShellLogger $logger;
/**
* @param ShellLogger $logger
*/
public function __construct(ShellLogger $logger)
{
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public static function isSupported(): bool
{
return true;
}
/**
* {@inheritdoc}
*/
public function onInput(Shell $shell, string $input)
{
$this->logger->logInput($input);
return null;
}
}

View File

@ -0,0 +1,82 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Shell;
/**
* Execution Loop Listener interface.
*/
interface Listener
{
/**
* Determines whether this listener should be active.
*/
public static function isSupported(): bool;
/**
* Called once before the REPL session starts.
*
* @param Shell $shell
*/
public function beforeRun(Shell $shell);
/**
* Called at the start of each loop.
*
* @param Shell $shell
*/
public function beforeLoop(Shell $shell);
/**
* Called on user input.
*
* Return a new string to override or rewrite user input.
*
* @param Shell $shell
* @param string $input
*
* @return string|null User input override
*/
public function onInput(Shell $shell, string $input);
/**
* Called before executing user code.
*
* Return a new string to override or rewrite user code.
*
* Note that this is run *after* the Code Cleaner, so if you return invalid
* or unsafe PHP here, it'll be executed without any of the safety Code
* Cleaner provides. This comes with the big kid warranty :)
*
* @param Shell $shell
* @param string $code
*
* @return string|null User code override
*/
public function onExecute(Shell $shell, string $code);
/**
* Called at the end of each loop.
*
* @param Shell $shell
*/
public function afterLoop(Shell $shell);
/**
* Called once after the REPL session ends.
*
* @param Shell $shell
* @param int $exitCode Exit code from the execution loop
*/
public function afterRun(Shell $shell, int $exitCode = 0);
}

View File

@ -0,0 +1,429 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Context;
use Psy\Exception\BreakException;
use Psy\Exception\InterruptException;
use Psy\Shell;
use Psy\Util\DependencyChecker;
/**
* An execution loop listener that forks the process before executing code.
*
* This is awesome, as the session won't die prematurely if user input includes
* a fatal error, such as redeclaring a class or function.
*/
class ProcessForker extends AbstractListener
{
private ?int $savegame = null;
/** @var resource */
private $up;
private bool $sigintHandlerInstalled = false;
private bool $restoreStty = false;
private ?string $originalStty = null;
public const PCNTL_FUNCTIONS = [
'pcntl_fork',
'pcntl_signal_dispatch',
'pcntl_signal',
'pcntl_waitpid',
'pcntl_wexitstatus',
];
public const POSIX_FUNCTIONS = [
'posix_getpid',
'posix_kill',
];
/**
* Process forker is supported if pcntl and posix extensions are available.
*/
public static function isSupported(): bool
{
return DependencyChecker::functionsAvailable(self::PCNTL_FUNCTIONS)
&& DependencyChecker::functionsAvailable(self::POSIX_FUNCTIONS);
}
/**
* Verify that all required pcntl functions are, in fact, available.
*
* @deprecated
*/
public static function isPcntlSupported(): bool
{
return DependencyChecker::functionsAvailable(self::PCNTL_FUNCTIONS);
}
/**
* Check whether required pcntl functions are disabled.
*
* @deprecated
*/
public static function disabledPcntlFunctions()
{
return DependencyChecker::functionsDisabled(self::PCNTL_FUNCTIONS);
}
/**
* Verify that all required posix functions are, in fact, available.
*
* @deprecated
*/
public static function isPosixSupported(): bool
{
return DependencyChecker::functionsAvailable(self::POSIX_FUNCTIONS);
}
/**
* Check whether required posix functions are disabled.
*
* @deprecated
*/
public static function disabledPosixFunctions()
{
return DependencyChecker::functionsDisabled(self::POSIX_FUNCTIONS);
}
/**
* Forks into a main and a loop process.
*
* The loop process will handle the evaluation of all instructions, then
* return its state via a socket upon completion.
*
* @param Shell $shell
*/
public function beforeRun(Shell $shell)
{
// Temporarily disable socket timeout for IPC sockets, to avoid losing our child process
// communication after 60 seconds.
$originalTimeout = @\ini_set('default_socket_timeout', '-1');
list($up, $down) = \stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP);
if ($originalTimeout !== false) {
@\ini_set('default_socket_timeout', $originalTimeout);
}
if (!$up) {
throw new \RuntimeException('Unable to create socket pair');
}
$pid = \pcntl_fork();
if ($pid < 0) {
throw new \RuntimeException('Unable to start execution loop');
} elseif ($pid > 0) {
// This is the main (parent) process. Install SIGINT handler and wait for child.
// We won't be needing this one.
\fclose($up);
// Install SIGINT handler in parent to interrupt child
\pcntl_async_signals(true);
$interrupted = false;
$sigintHandlerInstalled = \pcntl_signal(\SIGINT, function () use (&$interrupted, $pid) {
$interrupted = true;
// Send SIGINT to child so it can handle interruption gracefully
\posix_kill($pid, \SIGINT);
});
// Wait for a return value from the loop process.
$read = [$down];
$write = null;
$except = null;
do {
if ($interrupted) {
// Wait for child to exit (it should handle SIGINT gracefully)
\pcntl_waitpid($pid, $status);
// Try to read any final output from child before it exited
$content = @\stream_get_contents($down);
\fclose($down);
if ($sigintHandlerInstalled) {
\pcntl_signal(\SIGINT, \SIG_DFL);
}
$this->clearStdinBuffer();
// Restore scope variables and exit code if child sent any
// If child didn't send data, use the actual process exit status
$exitCode = \pcntl_wexitstatus($status);
if ($content) {
$data = @\unserialize($content);
if (\is_array($data) && isset($data['exitCode'], $data['scopeVars'])) {
$exitCode = $data['exitCode'];
$shell->setScopeVariables($data['scopeVars']);
}
}
throw new BreakException('Exiting main thread', $exitCode);
}
$n = @\stream_select($read, $write, $except, null);
if ($n === 0) {
throw new \RuntimeException('Process timed out waiting for execution loop');
}
if ($n === false) {
$err = \error_get_last();
$errMessage = \is_array($err) ? ($err['message'] ?? null) : null;
// If there's no error message, or it's an interrupted system call, just retry
if ($errMessage === null || \stripos($errMessage, 'interrupted system call') !== false) {
continue;
}
throw new \RuntimeException(\sprintf('Error waiting for execution loop: %s', $errMessage));
}
} while ($n < 1);
$content = \stream_get_contents($down);
\fclose($down);
// Wait for child to exit and get its exit status
\pcntl_waitpid($pid, $status);
// Restore default SIGINT handler
if ($sigintHandlerInstalled) {
\pcntl_signal(\SIGINT, \SIG_DFL);
}
// If child didn't send data, use the actual process exit status
$exitCode = \pcntl_wexitstatus($status);
if ($content) {
$data = @\unserialize($content);
if (\is_array($data) && isset($data['exitCode'], $data['scopeVars'])) {
$exitCode = $data['exitCode'];
$shell->setScopeVariables($data['scopeVars']);
}
}
throw new BreakException('Exiting main thread', $exitCode);
}
// This is the child process. It's going to do all the work.
if (!@\cli_set_process_title('psysh (loop)')) {
// Fall back to `setproctitle` if that wasn't succesful.
if (\function_exists('setproctitle')) {
@\setproctitle('psysh (loop)');
}
}
// We won't be needing this one.
\fclose($down);
// Save this; we'll need to close it in `afterRun`
$this->up = $up;
// Save original stty state so we can restore on exit
if (@\posix_isatty(\STDIN)) {
$this->originalStty = @\shell_exec('stty -g 2>/dev/null');
}
}
/**
* Install SIGINT handler before executing user code.
*/
public function onExecute(Shell $shell, string $code)
{
// Only handle SIGINT in the child process
if (isset($this->up)) {
// Ensure signal processing is enabled so Ctrl-C can interrupt execution
if (@\posix_isatty(\STDIN)) {
@\shell_exec('stty isig 2>/dev/null');
$this->restoreStty = true;
}
\pcntl_async_signals(true);
// Install SIGINT handler that throws exception during execution
\pcntl_signal(\SIGINT, function () {
throw new InterruptException('Ctrl+C');
});
}
return null;
}
/**
* Create a savegame at the start of each loop iteration.
*
* @param Shell $shell
*/
public function beforeLoop(Shell $shell)
{
$this->createSavegame();
}
/**
* Clean up old savegames at the end of each loop iteration.
*
* Restores terminal state and clears stdin if execution was interrupted.
*/
public function afterLoop(Shell $shell)
{
// Only handle cleanup in child process
if (isset($this->up)) {
// Restore default SIGINT handler after execution
if (!$this->sigintHandlerInstalled) {
\pcntl_signal(\SIGINT, \SIG_DFL);
}
// Restore terminal to raw mode after execution
// This prevents Ctrl-C at the prompt from generating SIGINT
if ($this->restoreStty) {
@\shell_exec('stty -isig 2>/dev/null');
$this->restoreStty = false;
}
}
// if there's an old savegame hanging around, let's kill it.
if (isset($this->savegame)) {
\posix_kill($this->savegame, \SIGKILL);
\pcntl_signal_dispatch();
}
}
/**
* After the REPL session ends, send the scope variables back up to the main
* thread (if this is a child thread).
*
* {@inheritdoc}
*/
public function afterRun(Shell $shell, int $exitCode = 0)
{
// We're a child thread. Send the scope variables and exit code back up to the main thread.
if (isset($this->up)) {
$data = $this->serializeReturn($exitCode, $shell->getScopeVariables(false));
// Suppress errors in case the pipe is broken (e.g., if parent was interrupted)
@\fwrite($this->up, $data);
@\fclose($this->up);
// Restore original terminal state before exiting.
//
// We set `stty isig` during execution, so Ctrl-C can interrupt, and
// `stty -isig` after, so readline can handle it at the prompt.
// Let's put things back the way we found them.
if ($this->originalStty !== null) {
@\shell_exec('stty '.\escapeshellarg(\trim($this->originalStty)).' 2>/dev/null');
}
\posix_kill(\posix_getpid(), \SIGKILL);
}
}
/**
* Create a savegame fork.
*
* The savegame contains the current execution state, and can be resumed in
* the event that the worker dies unexpectedly (for example, by encountering
* a PHP fatal error).
*/
private function createSavegame()
{
// the current process will become the savegame
$this->savegame = \posix_getpid();
$pid = \pcntl_fork();
if ($pid < 0) {
throw new \RuntimeException('Unable to create savegame fork');
} elseif ($pid > 0) {
// we're the savegame now... let's wait and see what happens
\pcntl_waitpid($pid, $status);
// worker exited cleanly, let's bail
if (!\pcntl_wexitstatus($status)) {
\posix_kill(\posix_getpid(), \SIGKILL);
}
// worker didn't exit cleanly, we'll need to have another go
// @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams - recursion exits via posix_kill above
$this->createSavegame();
}
}
/**
* Clear stdin buffer after interruption, in case SIGINT left the stream in a bad state.
*/
private function clearStdinBuffer(): void
{
if (!\defined('STDIN') || !\is_resource(\STDIN)) {
return;
}
// Check if the stream is still usable
$meta = @\stream_get_meta_data(\STDIN);
if (!$meta || ($meta['eof'] ?? false)) {
return;
}
// Drain any buffered input, suppressing I/O errors
@\stream_set_blocking(\STDIN, false);
while (@\fgetc(\STDIN) !== false) {
}
@\stream_set_blocking(\STDIN, true);
}
/**
* Serialize exit code and scope variables for transmission to parent process.
*
* A naïve serialization will run into issues if there is a Closure or
* SimpleXMLElement (among other things) in scope when exiting the execution
* loop. We'll just ignore these unserializable classes, and serialize what
* we can.
*
* @param int $exitCode Exit code from the child process
* @param array $scopeVars Scope variables to serialize
*
* @return string Serialized data array containing exitCode and scopeVars
*/
private function serializeReturn(int $exitCode, array $scopeVars): string
{
$serializable = [];
foreach ($scopeVars as $key => $value) {
// No need to return magic variables
if (Context::isSpecialVariableName($key)) {
continue;
}
// Resources and Closures don't error, but they don't serialize well either.
if (\is_resource($value) || $value instanceof \Closure) {
continue;
}
if (\PHP_VERSION_ID >= 80100 && $value instanceof \UnitEnum) {
// Enums defined in the REPL session can't be unserialized.
$ref = new \ReflectionObject($value);
if (\strpos($ref->getFileName(), ": eval()'d code") !== false) {
continue;
}
}
try {
@\serialize($value);
$serializable[$key] = $value;
} catch (\Throwable $e) {
// we'll just ignore this one...
}
}
return @\serialize([
'exitCode' => $exitCode,
'scopeVars' => $serializable,
]);
}
}

View File

@ -0,0 +1,145 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use PhpParser\Parser;
use Psy\ConfigPaths;
use Psy\Exception\ParseErrorException;
use Psy\OutputAware;
use Psy\ParserFactory;
use Psy\Shell;
use Symfony\Component\Console\Output\OutputInterface;
/**
* A runkit-based code reloader, which is pretty much magic.
*
* @todo Remove RunkitReloader once we drop support for PHP 7.x :(
*/
class RunkitReloader extends AbstractListener implements OutputAware
{
private Parser $parser;
private ?OutputInterface $output = null;
private array $timestamps = [];
/**
* Only enabled if Runkit is installed.
*/
public static function isSupported(): bool
{
// runkit_import was removed in runkit7-4.0.0a1
return \extension_loaded('runkit') || \extension_loaded('runkit7') && \function_exists('runkit_import');
}
/**
* Construct a Runkit Reloader.
*/
public function __construct()
{
$this->parser = (new ParserFactory())->createParser();
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
/**
* Reload code on input.
*/
public function onInput(Shell $shell, string $input)
{
$this->reload($shell);
return null;
}
/**
* Look through included files and update anything with a new timestamp.
*/
private function reload(Shell $shell)
{
\clearstatcache();
$modified = [];
foreach (\get_included_files() as $file) {
$timestamp = \filemtime($file);
if (!isset($this->timestamps[$file])) {
$this->timestamps[$file] = $timestamp;
continue;
}
if ($this->timestamps[$file] === $timestamp) {
continue;
}
if (!$this->lintFile($file)) {
$msg = \sprintf('Modified file "%s" could not be reloaded', $file);
$shell->writeException(new ParseErrorException($msg));
continue;
}
$modified[] = $file;
$this->timestamps[$file] = $timestamp;
}
if (\count($modified) === 0) {
return;
}
// Notify user about reload attempts
if ($this->output) {
if (\count($modified) === 1) {
$this->output->writeln(\sprintf('<whisper>Reloading %s</whisper>', ConfigPaths::prettyPath($modified[0])));
} else {
$this->output->writeln(\sprintf('<whisper>Reloading %d files</whisper>', \count($modified)));
}
}
foreach ($modified as $file) {
$flags = (
RUNKIT_IMPORT_FUNCTIONS |
RUNKIT_IMPORT_CLASSES |
RUNKIT_IMPORT_CLASS_METHODS |
RUNKIT_IMPORT_CLASS_CONSTS |
RUNKIT_IMPORT_CLASS_PROPS |
RUNKIT_IMPORT_OVERRIDE
);
// these two const cannot be used with RUNKIT_IMPORT_OVERRIDE in runkit7
if (\extension_loaded('runkit7')) {
$flags &= ~RUNKIT_IMPORT_CLASS_PROPS & ~RUNKIT_IMPORT_CLASS_STATIC_PROPS;
runkit7_import($file, $flags);
} else {
runkit_import($file, $flags);
}
}
}
/**
* Check if file has valid PHP syntax.
*/
private function lintFile(string $file): bool
{
// first try to parse it
try {
$this->parser->parse(\file_get_contents($file));
} catch (\Throwable $e) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,126 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use Psy\Exception\InterruptException;
use Psy\Shell;
use Psy\Util\DependencyChecker;
/**
* A signal handler for interrupting execution with Ctrl-C, used when process forking is disabled.
*/
class SignalHandler extends AbstractListener
{
private bool $sigintHandlerInstalled = false;
private bool $restoreStty = false;
private bool $wasInterrupted = false;
private ?string $originalStty = null;
public const PCNTL_FUNCTIONS = [
'pcntl_signal',
'pcntl_async_signals',
];
public const POSIX_FUNCTIONS = [
'posix_isatty',
];
/**
* Signal handler is supported if pcntl and posix extensions are available.
*/
public static function isSupported(): bool
{
return DependencyChecker::functionsAvailable(self::PCNTL_FUNCTIONS)
&& DependencyChecker::functionsAvailable(self::POSIX_FUNCTIONS);
}
/**
* Save original stty state before the REPL starts.
*/
public function beforeRun(Shell $shell)
{
if (@\posix_isatty(\STDIN)) {
$this->originalStty = @\shell_exec('stty -g 2>/dev/null');
}
}
/**
* Install SIGINT handler before executing user code.
*/
public function onExecute(Shell $shell, string $code)
{
$this->wasInterrupted = false;
// Ensure signal processing is enabled so Ctrl-C can interrupt execution
if (@\posix_isatty(\STDIN)) {
@\shell_exec('stty isig 2>/dev/null');
$this->restoreStty = true;
}
\pcntl_async_signals(true);
// Install SIGINT handler that throws exception during execution
$interrupted = &$this->wasInterrupted;
$this->sigintHandlerInstalled = \pcntl_signal(\SIGINT, function () use (&$interrupted) {
$interrupted = true;
throw new InterruptException('Ctrl+C');
});
return null;
}
/**
* Called at the end of each loop.
*
* Restores terminal state and clears stdin if execution was interrupted.
*/
public function afterLoop(Shell $shell)
{
// Restore default SIGINT handler after execution
if ($this->sigintHandlerInstalled) {
\pcntl_signal(\SIGINT, \SIG_DFL);
$this->sigintHandlerInstalled = false;
}
// Restore terminal to raw mode after execution
// This prevents Ctrl-C at the prompt from generating SIGINT
if ($this->restoreStty) {
@\shell_exec('stty -isig 2>/dev/null');
$this->restoreStty = false;
}
// Clear any pending input from the interrupted stdin stream
// The SIGINT may have left the stream in a bad state
if ($this->wasInterrupted && \defined('STDIN') && \is_resource(\STDIN)) {
// Check if the stream is still usable
$meta = @\stream_get_meta_data(\STDIN);
if ($meta && !($meta['eof'] ?? false)) {
// Drain any buffered input, suppressing I/O errors
@\stream_set_blocking(\STDIN, false);
while (@\fgetc(\STDIN) !== false) {
}
@\stream_set_blocking(\STDIN, true);
}
$this->wasInterrupted = false;
}
}
/**
* Restore original terminal state when the REPL exits.
*/
public function afterRun(Shell $shell, int $exitCode = 0)
{
if ($this->originalStty !== null) {
@\shell_exec('stty '.\escapeshellarg(\trim($this->originalStty)).' 2>/dev/null');
}
}
}

View File

@ -0,0 +1,301 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use PhpParser\NodeTraverser;
use PhpParser\Parser;
use PhpParser\PrettyPrinter;
use Psy\ConfigPaths;
use Psy\Exception\ParseErrorException;
use Psy\OutputAware;
use Psy\ParserFactory;
use Psy\Shell;
use Psy\Util\DependencyChecker;
use Symfony\Component\Console\Output\OutputInterface;
/**
* A uopz-based code reloader for modern PHP.
*
* This reloader uses the uopz extension to dynamically reload modified files
* without restarting the REPL session. It parses changed files and uses uopz
* functions to override methods, functions, and constants.
*
* Reload flow:
* 1. On each input, check included files for timestamp changes
* 2. Parse modified files and reload safe elements (methods, unconditional functions)
* 3. Skip unsafe elements (conditional functions/constants) and track in skippedFiles
* 4. When `yolo` command enables force-reload, re-process skipped files with
* safety checks bypassed
*
* Known limitations:
* - Cannot add/remove class properties
* - Cannot change class inheritance or interfaces
* - Cannot change method signatures (parameter types/counts)
*
* However, it can:
* - Reload method implementations (including private/protected)
* - Reload function implementations
* - Reload class and global constants
* - Add new methods and functions
*/
class UopzReloader extends AbstractListener implements OutputAware
{
private Parser $parser;
private PrettyPrinter\Standard $printer;
private ?OutputInterface $output = null;
private ?Shell $shell = null;
/** @var array<string, int> File path => last processed timestamp */
private array $timestamps = [];
/**
* File paths with skipped elements, awaiting force-reload via yolo.
*
* @var array<string, int> File path => last processed timestamp
*/
private array $skippedFiles = [];
/** @var bool Whether to bypass safety warnings (set by yolo command) */
private bool $forceReload = false;
/**
* Only enabled if uopz extension is installed with required functions.
*
* Requires uopz 5.0+ which provides uopz_set_return() and uopz_redefine().
*/
public static function isSupported(): bool
{
return \extension_loaded('uopz') && DependencyChecker::functionsAvailable([
'uopz_set_return',
'uopz_redefine',
'uopz_unset_return',
'uopz_undefine',
]);
}
/**
* Construct a Uopz Reloader.
*/
public function __construct()
{
$this->parser = (new ParserFactory())->createParser();
$this->printer = new PrettyPrinter\Standard();
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
/**
* Enable or disable force-reload mode.
*
* When enabled, safety checks are bypassed and any pending skipped files
* are immediately re-processed.
*/
public function setForceReload(bool $force)
{
$this->forceReload = $force;
// Re-process any skipped files now that force-reload is enabled
if ($force && !empty($this->skippedFiles) && $this->shell !== null) {
$this->reloadSkippedFiles();
}
}
/**
* Re-process files that were previously skipped.
*/
private function reloadSkippedFiles(): void
{
$files = $this->skippedFiles;
$this->skippedFiles = [];
if (\count($files) === 1) {
$this->writeInfo(\sprintf('YOLO: Force-reloading %s', ConfigPaths::prettyPath(\array_key_first($files))));
} else {
$this->writeInfo(\sprintf('YOLO: Force-reloading %d files', \count($files)));
}
foreach ($files as $file => $timestamp) {
$this->reloadFile($file);
$this->timestamps[$file] = $timestamp;
}
}
/**
* Reload code on input.
*/
public function onInput(Shell $shell, string $input)
{
$this->shell = $shell;
$this->reload();
return null;
}
/**
* Look through included files and update anything with a new timestamp.
*/
private function reload(): void
{
\clearstatcache();
$modified = [];
foreach (\get_included_files() as $file) {
// Skip files that no longer exist
if (!\file_exists($file)) {
continue;
}
$timestamp = \filemtime($file);
if (!isset($this->timestamps[$file])) {
$this->timestamps[$file] = $timestamp;
continue;
}
if ($this->timestamps[$file] === $timestamp) {
continue;
}
if (!$this->lintFile($file)) {
$this->writeError(\sprintf('Modified file "%s" has syntax errors and cannot be reloaded', ConfigPaths::prettyPath($file)));
continue;
}
$modified[$file] = $timestamp;
}
if (\count($modified) === 0) {
return;
}
// Notify user about reload attempts
if ($this->forceReload) {
if (\count($modified) === 1) {
$this->writeInfo(\sprintf('YOLO: Force-reloading %s', ConfigPaths::prettyPath(\array_key_first($modified))));
} else {
$this->writeInfo(\sprintf('YOLO: Force-reloading %d files', \count($modified)));
}
} else {
if (\count($modified) === 1) {
$this->writeInfo(\sprintf('Reloading %s', ConfigPaths::prettyPath(\array_key_first($modified))));
} else {
$this->writeInfo(\sprintf('Reloading %d files', \count($modified)));
}
}
foreach ($modified as $file => $timestamp) {
$hadSkips = $this->reloadFile($file);
$this->timestamps[$file] = $timestamp;
if ($hadSkips) {
// Track for later force-reload via yolo
$this->skippedFiles[$file] = $timestamp;
} else {
unset($this->skippedFiles[$file]);
}
}
}
/**
* Reload a single file by parsing it and applying uopz overrides.
*
* @return bool True if any elements were skipped (need yolo to force)
*/
private function reloadFile(string $file): bool
{
try {
$code = \file_get_contents($file);
$ast = $this->parser->parse($code);
if ($ast === null) {
return false;
}
$traverser = new NodeTraverser();
$reloader = new UopzReloaderVisitor($this->printer, $this->forceReload);
$traverser->addVisitor($reloader);
$traverser->traverse($ast);
// Check if there were any warnings about limitations
if ($reloader->hasWarnings()) {
foreach ($reloader->getWarnings() as $warning) {
$this->writeWarning($warning);
}
}
return $reloader->hasSkips();
} catch (\Throwable $e) {
$this->writeError(\sprintf('Failed to reload %s: %s', ConfigPaths::prettyPath($file), $e->getMessage()));
return false;
}
}
/**
* Write an info message.
*/
private function writeInfo(string $message): void
{
if ($this->output) {
$this->output->writeln(\sprintf('<whisper>%s</whisper>', $message));
}
}
/**
* Write a warning message.
*/
private function writeWarning(string $message): void
{
if ($this->output) {
$this->output->writeln(\sprintf('<comment>Warning: %s</comment>', $message));
}
}
/**
* Write an error message using shell exception handling.
*/
private function writeError(string $message): void
{
if ($this->shell) {
try {
$this->shell->writeException(new ParseErrorException($message));
return;
} catch (\Throwable $e) {
// Shell not fully initialized, fall back to output
}
}
if ($this->output) {
$this->output->writeln(\sprintf('<error>Error: %s</error>', $message));
}
}
/**
* Check if file has valid PHP syntax.
*/
private function lintFile(string $file): bool
{
try {
$this->parser->parse(\file_get_contents($file));
} catch (\Throwable $e) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,646 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\ExecutionLoop;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PhpParser\NodeVisitorAbstract;
use PhpParser\PrettyPrinter;
use ReflectionClass;
/**
* AST visitor that reloads code definitions using uopz.
*
* Traverses the parsed AST and uses uopz to reload:
* - Class methods (via uopz_set_return with closure)
* - Functions (via uopz_set_return)
* - Class and global constants (via uopz_redefine)
*
* Safety checks:
* - Conditional code (functions/constants inside if blocks) is skipped by default
* because reloading may not match runtime conditions
* - Static variables in functions/methods trigger a warning (state will reset)
* - Structural changes (new properties, inheritance) cannot be applied
*
* When force-reload is enabled (via `yolo` command), safety checks are bypassed
* and the code is reloaded anyway.
*/
class UopzReloaderVisitor extends NodeVisitorAbstract
{
private PrettyPrinter\Standard $printer;
/** @var bool Whether to bypass safety warnings */
private bool $forceReload;
private string $namespace = '';
private ?string $currentClass = null;
private ?string $currentFunction = null;
/** @var string[] Warning messages generated during traversal */
private array $warnings = [];
/** @var bool Whether any elements were skipped (not force-reloaded) */
private bool $hasSkips = false;
/** @var int Nesting depth inside conditional/control structures */
private int $conditionalDepth = 0;
/**
* @param bool $forceReload Whether to bypass safety warnings
*/
public function __construct(PrettyPrinter\Standard $printer, bool $forceReload = false)
{
$this->printer = $printer;
$this->forceReload = $forceReload;
}
/**
* Check if any warnings were generated during reloading.
*/
public function hasWarnings(): bool
{
return \count($this->warnings) > 0;
}
/**
* Get all warnings generated during reloading.
*
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* Check if any elements were skipped during reloading.
*/
public function hasSkips(): bool
{
return $this->hasSkips;
}
/**
* Add a warning message.
*/
private function addWarning(string $message): void
{
$this->warnings[] = $message;
}
/**
* {@inheritdoc}
*/
public function enterNode(Node $node)
{
// Track namespace
if ($node instanceof Stmt\Namespace_) {
$this->namespace = $node->name ? $node->name->toString() : '';
}
// Track current class and check for limitations
if ($node instanceof Stmt\Class_ || $node instanceof Stmt\Interface_ || $node instanceof Stmt\Trait_) {
$name = $node->name ? $node->name->toString() : null;
$this->currentClass = $name ? $this->getFullyQualifiedName($name) : null;
if ($this->currentClass) {
$this->checkClassLimitations($this->currentClass, $node);
}
}
// Track when we enter conditional/control structures at global scope
if ($this->currentClass === null && $this->currentFunction === null && $this->isControlStructure($node)) {
$this->conditionalDepth++;
}
// Detect side effects at global/namespace scope (but not if we're already tracking it as conditional)
if ($this->currentClass === null && $this->currentFunction === null && $this->conditionalDepth === 0) {
$this->checkForSideEffects($node);
}
// Reload class methods
if ($node instanceof Stmt\ClassMethod && $this->currentClass) {
$this->reloadMethod($this->currentClass, $node);
}
// Reload functions (skip if inside conditional, unless force mode)
if ($node instanceof Stmt\Function_) {
// Track that we're entering a function
$this->currentFunction = $node->name->toString();
if ($this->conditionalDepth > 0 && $this->currentClass === null) {
$funcName = $node->name->toString();
$snippet = \sprintf('if (...) { function %s() ... }', $funcName);
if ($this->forceReload) {
$this->addWarning(\sprintf('YOLO: Force-reloaded %s', $snippet));
$this->reloadFunction($node);
} else {
$this->addWarning(\sprintf('Skipped conditional: %s (use `yolo` to force)', $snippet));
$this->hasSkips = true;
}
} else {
$this->reloadFunction($node);
}
}
// Reload constants
if ($node instanceof Stmt\ClassConst && $this->currentClass) {
$this->reloadClassConstants($this->currentClass, $node);
}
if ($node instanceof Stmt\Const_) {
if ($this->conditionalDepth > 0 && $this->currentClass === null) {
$constNode = $node->consts[0] ?? null;
$constName = $constNode ? $constNode->name->toString() : 'CONST';
$snippet = \sprintf('if (...) { const %s = ...; }', $constName);
if ($this->forceReload) {
$this->addWarning(\sprintf('YOLO: Force-reloaded %s', $snippet));
$this->reloadGlobalConstants($node);
} else {
$this->addWarning(\sprintf('Skipped conditional: %s (use `yolo` to force)', $snippet));
$this->hasSkips = true;
}
} else {
$this->reloadGlobalConstants($node);
}
}
return null;
}
/**
* {@inheritdoc}
*/
public function leaveNode(Node $node)
{
// Clear current class when leaving class/interface/trait
if ($node instanceof Stmt\Class_ || $node instanceof Stmt\Interface_ || $node instanceof Stmt\Trait_) {
$this->currentClass = null;
}
// Clear current function when leaving function
if ($node instanceof Stmt\Function_) {
$this->currentFunction = null;
}
// Track when we leave conditional/control structures at global scope
if ($this->currentClass === null && $this->currentFunction === null && $this->isControlStructure($node)) {
$this->conditionalDepth--;
}
return null;
}
/**
* Reload a class method using uopz_set_return.
*/
private function reloadMethod(string $className, Stmt\ClassMethod $method): void
{
$methodName = $method->name->toString();
// Skip abstract methods
if ($method->isAbstract()) {
return;
}
// Check for static variables in method body
if ($this->hasStaticVariables($method->stmts)) {
$snippet = \sprintf('%s::%s() { static $var = ...; }', $className, $methodName);
$this->addWarning(\sprintf('Static vars will reset: %s', $snippet));
}
$closure = $this->createClosure($method->params, $method->stmts, $method->returnType);
if ($closure !== null) {
try {
\uopz_set_return($className, $methodName, $closure, true);
} catch (\Throwable $e) {
$this->addWarning(\sprintf('Failed to reload %s::%s(): %s', $className, $methodName, $e->getMessage()));
}
}
}
/**
* Reload a function using uopz_set_return.
*/
private function reloadFunction(Stmt\Function_ $function): void
{
$functionName = $this->getFullyQualifiedName($function->name->toString());
// New function; just define it via eval
if (!\function_exists($functionName)) {
try {
$code = '';
if ($this->namespace !== '') {
$code .= 'namespace '.$this->namespace.'; ';
}
$code .= $this->printer->prettyPrint([$function]);
eval($code);
} catch (\Throwable $e) {
$this->addWarning(\sprintf('Failed to add %s(): %s', $functionName, $e->getMessage()));
}
return;
}
// Existing function; check for static variables (state will reset on reload)
if ($this->hasStaticVariables($function->stmts)) {
$snippet = \sprintf('%s() { static $var = ...; }', $functionName);
$this->addWarning(\sprintf('Static vars will reset: %s', $snippet));
}
// Use uopz to override existing function
$closure = $this->createClosure($function->params, $function->stmts, $function->returnType);
if ($closure !== null) {
try {
\uopz_set_return($functionName, $closure, true);
} catch (\Throwable $e) {
$this->addWarning(\sprintf('Failed to reload %s(): %s', $functionName, $e->getMessage()));
}
}
}
/**
* Create a closure from parameters and statements.
*
* @param Node\Param[] $params
* @param Stmt[]|null $stmts
* @param Node|null $returnType
*
* @return \Closure|null
*/
private function createClosure(array $params, ?array $stmts, ?Node $returnType = null): ?\Closure
{
$paramStrs = [];
foreach ($params as $param) {
$paramStr = '';
if ($param->type) {
$paramStr .= $this->printer->prettyPrint([$param->type]).' ';
}
if ($param->variadic) {
$paramStr .= '...';
}
if ($param->byRef) {
$paramStr .= '&';
}
$paramStr .= '$'.$param->var->name;
if ($param->default) {
$paramStr .= ' = '.$this->printer->prettyPrintExpr($param->default);
}
$paramStrs[] = $paramStr;
}
$paramList = \implode(', ', $paramStrs);
$returnTypeStr = '';
if ($returnType !== null) {
$returnTypeStr = ': '.$this->printer->prettyPrint([$returnType]);
}
$body = '';
if ($stmts) {
$bodyStmts = [];
foreach ($stmts as $stmt) {
$bodyStmts[] = $this->printer->prettyPrint([$stmt]);
}
$body = \implode("\n", $bodyStmts);
}
$closureCode = \sprintf("return function(%s)%s {\n%s\n};", $paramList, $returnTypeStr, $body);
try {
return eval($closureCode);
} catch (\Throwable $e) {
return null;
}
}
/**
* Reload class constants using uopz_redefine.
*/
private function reloadClassConstants(string $className, Stmt\ClassConst $const): void
{
foreach ($const->consts as $constNode) {
$constName = $constNode->name->toString();
$value = $this->evaluateConstValue($constNode->value);
try {
\uopz_redefine($className, $constName, $value);
} catch (\Throwable $e) {
$this->addWarning(\sprintf('Failed to reload %s::%s: %s', $className, $constName, $e->getMessage()));
}
}
}
/**
* Reload global constants using uopz_redefine.
*/
private function reloadGlobalConstants(Stmt\Const_ $const): void
{
foreach ($const->consts as $constNode) {
$constName = $this->getFullyQualifiedName($constNode->name->toString());
$value = $this->evaluateConstValue($constNode->value);
try {
\uopz_redefine($constName, $value);
} catch (\Throwable $e) {
$this->addWarning(\sprintf('Failed to reload %s: %s', $constName, $e->getMessage()));
}
}
}
/**
* Evaluate a constant value from AST node.
*
* @return mixed
*/
private function evaluateConstValue(Expr $expr)
{
// For simple scalar values, we can evaluate directly
try {
$code = '<?php return '.$this->printer->prettyPrintExpr($expr).';';
return eval(\substr($code, 6));
} catch (\Throwable $e) {
return null;
}
}
/**
* Get a fully-qualified name (class, function, constant, etc).
*/
private function getFullyQualifiedName(string $name): string
{
if ($this->namespace && \strpos($name, '\\') !== 0) {
return $this->namespace.'\\'.$name;
}
return $name;
}
/**
* Check if a node is a control structure.
*/
private function isControlStructure(Node $node): bool
{
return $node instanceof Stmt\If_ ||
$node instanceof Stmt\Switch_ ||
$node instanceof Stmt\For_ ||
$node instanceof Stmt\Foreach_ ||
$node instanceof Stmt\While_ ||
$node instanceof Stmt\Do_ ||
$node instanceof Stmt\TryCatch;
}
/**
* Check if statements contain static variable declarations.
*
* @param Stmt[]|null $stmts
*/
private function hasStaticVariables(?array $stmts): bool
{
if ($stmts === null) {
return false;
}
foreach ($stmts as $stmt) {
// Direct static declaration
if ($stmt instanceof Stmt\Static_) {
return true;
}
// Recursively check nested structures (if/for/while/etc)
if ($stmt instanceof Stmt\If_) {
if ($this->hasStaticVariables($stmt->stmts)) {
return true;
}
foreach ($stmt->elseifs as $elseif) {
if ($this->hasStaticVariables($elseif->stmts)) {
return true;
}
}
if ($stmt->else && $this->hasStaticVariables($stmt->else->stmts)) {
return true;
}
}
if ($stmt instanceof Stmt\For_ ||
$stmt instanceof Stmt\Foreach_ ||
$stmt instanceof Stmt\While_ ||
$stmt instanceof Stmt\Do_) {
if ($this->hasStaticVariables($stmt->stmts)) {
return true;
}
}
if ($stmt instanceof Stmt\Switch_) {
foreach ($stmt->cases as $case) {
if ($this->hasStaticVariables($case->stmts)) {
return true;
}
}
}
if ($stmt instanceof Stmt\TryCatch) {
if ($this->hasStaticVariables($stmt->stmts)) {
return true;
}
foreach ($stmt->catches as $catch) {
if ($this->hasStaticVariables($catch->stmts)) {
return true;
}
}
if ($stmt->finally && $this->hasStaticVariables($stmt->finally->stmts)) {
return true;
}
}
}
return false;
}
/**
* Check for side effects that won't be re-executed on reload.
*
* Detects top-level code that has side effects (function calls, variable
* assignments, etc.) which will not be re-run when the file is reloaded.
*/
private function checkForSideEffects(Node $node): void
{
// Skip declarations (these are handled by uopz)
if ($node instanceof Stmt\Class_ ||
$node instanceof Stmt\Interface_ ||
$node instanceof Stmt\Trait_ ||
$node instanceof Stmt\Function_ ||
$node instanceof Stmt\Const_ ||
$node instanceof Stmt\Namespace_ ||
$node instanceof Stmt\Use_) {
return;
}
// Only check statements, not expressions (to avoid duplicate warnings)
// Expression statements contain the actual expression
if ($node instanceof Stmt\Expression) {
$expr = $node->expr;
$snippet = $this->printer->prettyPrintExpr($expr);
// Truncate long snippets
if (\strlen($snippet) > 50) {
$snippet = \substr($snippet, 0, 47).'...';
}
$this->addWarning(\sprintf('Not re-run: %s', $snippet));
return;
}
// Echo/print statements
if ($node instanceof Stmt\Echo_) {
$firstExpr = $node->exprs[0] ?? null;
$snippet = $firstExpr !== null
? 'echo '.$this->printer->prettyPrintExpr($firstExpr)
: 'echo ...';
if (\strlen($snippet) > 50) {
$snippet = \substr($snippet, 0, 47).'...';
}
$this->addWarning(\sprintf('Not re-run: %s', $snippet));
return;
}
// Global variable declarations
if ($node instanceof Stmt\Global_) {
$varNames = [];
foreach ($node->vars as $var) {
if ($var instanceof Expr\Variable) {
$varNames[] = '$'.$var->name;
}
}
$snippet = 'global '.\implode(', ', $varNames);
$this->addWarning(\sprintf('Not re-run: %s', $snippet));
return;
}
// Static variable declarations (inside functions are OK, but top-level would be unusual)
if ($node instanceof Stmt\Static_) {
$varNames = [];
foreach ($node->vars as $var) {
$varNames[] = '$'.$var->var->name;
}
$snippet = 'static '.\implode(', ', $varNames);
$this->addWarning(\sprintf('Not re-run: %s', $snippet));
return;
}
// If/switch/for/while/etc control structures at top level
if ($this->isControlStructure($node)) {
$type = 'if';
if ($node instanceof Stmt\Switch_) {
$type = 'switch';
} elseif ($node instanceof Stmt\For_) {
$type = 'for';
} elseif ($node instanceof Stmt\Foreach_) {
$type = 'foreach';
} elseif ($node instanceof Stmt\While_) {
$type = 'while';
} elseif ($node instanceof Stmt\Do_) {
$type = 'do-while';
} elseif ($node instanceof Stmt\TryCatch) {
$type = 'try-catch';
}
$this->addWarning(\sprintf('Not re-run: %s (...) { ... }', $type));
return;
}
}
/**
* Check for known limitations when reloading a class.
*/
private function checkClassLimitations(string $className, Node $node): void
{
// Check if class already exists
if (!\class_exists($className, false) && !\interface_exists($className, false) && !\trait_exists($className, false)) {
// New class/interface/trait - uopz cannot add these
$type = $node instanceof Stmt\Interface_ ? 'interface' : ($node instanceof Stmt\Trait_ ? 'trait' : 'class');
$this->addWarning(\sprintf('Cannot add %s %s', $type, $className));
return;
}
// For existing classes, check for structural changes
if (!($node instanceof Stmt\Class_)) {
return;
}
// Check for new properties (cannot be added)
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Stmt\Property) {
foreach ($stmt->props as $prop) {
$propName = $prop->name->toString();
if (!\property_exists($className, $propName)) {
$visibility = $stmt->isPublic() ? 'public' : ($stmt->isProtected() ? 'protected' : 'private');
$static = $stmt->isStatic() ? 'static ' : '';
$this->addWarning(\sprintf('Cannot add %s$%s', $static.$visibility.' ', $propName));
}
}
}
// Check for new methods (will try to add but may fail silently)
if ($stmt instanceof Stmt\ClassMethod) {
$methodName = $stmt->name->toString();
if (!\method_exists($className, $methodName)) {
$this->addWarning(\sprintf('Cannot add %s::%s()', $className, $methodName));
}
}
}
// Check for inheritance changes
if ($node->extends) {
$newParent = $node->extends->toString();
$reflection = new ReflectionClass($className);
$currentParent = $reflection->getParentClass();
if ($currentParent && $currentParent->getName() !== $this->getFullyQualifiedName($newParent)) {
$this->addWarning(\sprintf('Cannot change parent of %s', $className));
}
}
// Check for interface changes
if ($node->implements) {
$newInterfaces = \array_map(function ($interface) {
return $this->getFullyQualifiedName($interface->toString());
}, $node->implements);
$reflection = new ReflectionClass($className);
$currentInterfaces = $reflection->getInterfaceNames();
$added = \array_diff($newInterfaces, $currentInterfaces);
$removed = \array_diff($currentInterfaces, $newInterfaces);
if (\count($added) > 0 || \count($removed) > 0) {
$this->addWarning(\sprintf('Cannot change interfaces of %s', $className));
}
}
}
}

View File

@ -0,0 +1,97 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
use Psy\Exception\BreakException;
use Psy\Exception\InterruptException;
use Psy\Exception\ThrowUpException;
/**
* The Psy Shell's execution loop scope.
*
* @todo Switch ExecutionClosure to a generator and get rid of the duplicate closure implementations?
*/
class ExecutionLoopClosure extends ExecutionClosure
{
/**
* @param Shell $__psysh__
*/
public function __construct(Shell $__psysh__)
{
$this->setClosure($__psysh__, function () use ($__psysh__) {
// Restore execution scope variables
// @phan-suppress-next-line PhanTypeNonVarPassByRef assigning to a temp variable pollutes scope
\extract($__psysh__->getScopeVariables(false));
while (true) {
$__psysh__->beforeLoop();
try {
$__psysh__->getInput();
try {
// Pull in any new execution scope variables
if ($__psysh__->getLastExecSuccess()) {
// @phan-suppress-next-line PhanTypeNonVarPassByRef assigning to a temp variable pollutes scope
\extract($__psysh__->getScopeVariablesDiff(\get_defined_vars()));
}
// Buffer stdout; we'll need it later
\ob_start([$__psysh__, 'writeStdout'], 1);
// Convert all errors to exceptions
\set_error_handler([$__psysh__, 'handleError']);
// Evaluate the current code buffer
$_ = eval($__psysh__->onExecute($__psysh__->flushCode() ?: ExecutionClosure::NOOP_INPUT));
} catch (\Throwable $_e) {
// Clean up on our way out.
if (\ob_get_level() > 0) {
\ob_end_clean();
}
throw $_e;
} finally {
// Won't be needing this anymore
\restore_error_handler();
}
// Flush stdout (write to shell output, plus save to magic variable)
\ob_end_flush();
// Save execution scope variables for next time
$__psysh__->setScopeVariables(\get_defined_vars());
$__psysh__->writeReturnValue($_);
} catch (BreakException $_e) {
// exit() or ctrl-d exits the REPL
$__psysh__->writeException($_e);
return $_e->getCode();
} catch (ThrowUpException $_e) {
// `throw-up` command throws the exception out of the REPL
$__psysh__->writeException($_e);
throw $_e;
} catch (InterruptException $_e) {
// ctrl-c stops execution, but continues the REPL
$__psysh__->writeException($_e);
} catch (\Throwable $_e) {
// Everything else gets printed to the shell output
$__psysh__->writeException($_e);
} finally {
$__psysh__->afterLoop();
}
}
});
}
}

View File

@ -0,0 +1,319 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Formatter;
use Psy\Exception\RuntimeException;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* A pretty-printer for code.
*/
class CodeFormatter implements ReflectorFormatter
{
const LINE_MARKER = ' <urgent>></urgent> ';
const NO_LINE_MARKER = ' ';
const HIGHLIGHT_DEFAULT = 'default';
const HIGHLIGHT_KEYWORD = 'keyword';
const HIGHLIGHT_PUBLIC = 'public';
const HIGHLIGHT_PROTECTED = 'protected';
const HIGHLIGHT_PRIVATE = 'private';
const HIGHLIGHT_CONST = 'const';
const HIGHLIGHT_NUMBER = 'number';
const HIGHLIGHT_STRING = 'string';
const HIGHLIGHT_COMMENT = 'code_comment';
const HIGHLIGHT_INLINE_HTML = 'inline_html';
private const TOKEN_MAP = [
// Not highlighted
\T_OPEN_TAG => self::HIGHLIGHT_DEFAULT,
\T_OPEN_TAG_WITH_ECHO => self::HIGHLIGHT_DEFAULT,
\T_CLOSE_TAG => self::HIGHLIGHT_DEFAULT,
\T_STRING => self::HIGHLIGHT_DEFAULT,
\T_VARIABLE => self::HIGHLIGHT_DEFAULT,
\T_NS_SEPARATOR => self::HIGHLIGHT_DEFAULT,
// Visibility
\T_PUBLIC => self::HIGHLIGHT_PUBLIC,
\T_PROTECTED => self::HIGHLIGHT_PROTECTED,
\T_PRIVATE => self::HIGHLIGHT_PRIVATE,
// Constants
\T_DIR => self::HIGHLIGHT_CONST,
\T_FILE => self::HIGHLIGHT_CONST,
\T_METHOD_C => self::HIGHLIGHT_CONST,
\T_NS_C => self::HIGHLIGHT_CONST,
\T_LINE => self::HIGHLIGHT_CONST,
\T_CLASS_C => self::HIGHLIGHT_CONST,
\T_FUNC_C => self::HIGHLIGHT_CONST,
\T_TRAIT_C => self::HIGHLIGHT_CONST,
// Types
\T_DNUMBER => self::HIGHLIGHT_NUMBER,
\T_LNUMBER => self::HIGHLIGHT_NUMBER,
\T_ENCAPSED_AND_WHITESPACE => self::HIGHLIGHT_STRING,
\T_CONSTANT_ENCAPSED_STRING => self::HIGHLIGHT_STRING,
// Comments
\T_COMMENT => self::HIGHLIGHT_COMMENT,
\T_DOC_COMMENT => self::HIGHLIGHT_COMMENT,
// @todo something better here?
\T_INLINE_HTML => self::HIGHLIGHT_INLINE_HTML,
];
/**
* Format the code represented by $reflector for shell output.
*
* @param \Reflector $reflector
*
* @return string formatted code
*/
public static function format(\Reflector $reflector): string
{
if (self::isReflectable($reflector)) {
// @phan-suppress-next-line PhanUndeclaredMethod - getFileName/getEndLine exist on ReflectionClass/ReflectionFunctionAbstract
if ($code = @\file_get_contents($reflector->getFileName())) {
// @phan-suppress-next-line PhanUndeclaredMethod - getEndLine exists on ReflectionClass/ReflectionFunctionAbstract
return self::formatCode($code, self::getStartLine($reflector), $reflector->getEndLine());
}
}
throw new RuntimeException('Source code unavailable');
}
/**
* Format code for shell output.
*
* Optionally, restrict by $startLine and $endLine line numbers, or pass $markLine to add a line marker.
*
* @param string $code
* @param int $startLine
* @param int|null $endLine
* @param int|null $markLine
*
* @return string formatted code
*/
public static function formatCode(string $code, int $startLine = 1, ?int $endLine = null, ?int $markLine = null): string
{
$spans = self::tokenizeSpans($code);
$lines = self::splitLines($spans, $startLine, $endLine);
$lines = self::formatLines($lines);
$lines = self::numberLines($lines, $markLine);
return \implode('', \iterator_to_array($lines));
}
/**
* Get the start line for a given Reflector.
*
* Tries to incorporate doc comments if possible.
*
* This is typehinted as \Reflector but we've narrowed the input via self::isReflectable already.
*
* @param \ReflectionClass|\ReflectionFunctionAbstract $reflector
*/
private static function getStartLine(\Reflector $reflector): int
{
$startLine = $reflector->getStartLine();
if ($docComment = $reflector->getDocComment()) {
$startLine -= \preg_match_all('/(\r\n?|\n)/', $docComment) + 1;
}
return \max($startLine, 1);
}
/**
* Split code into highlight spans.
*
* Tokenize via \token_get_all, then map these tokens to internal highlight types, combining
* adjacent spans of the same highlight type.
*
* @todo consider switching \token_get_all() out for PHP-Parser-based formatting at some point.
*
* @param string $code
*
* @return \Generator [$spanType, $spanText] highlight spans
*/
private static function tokenizeSpans(string $code): \Generator
{
$spanType = null;
$buffer = '';
foreach (\token_get_all($code) as $token) {
$nextType = self::nextHighlightType($token, $spanType);
$spanType = $spanType ?: $nextType;
if ($spanType !== $nextType) {
yield [$spanType, $buffer];
$spanType = $nextType;
$buffer = '';
}
$buffer .= \is_array($token) ? $token[1] : $token;
}
if ($spanType !== null && $buffer !== '') {
yield [$spanType, $buffer];
}
}
/**
* Given a token and the current highlight span type, compute the next type.
*
* @param array|string $token \token_get_all token
* @param string|null $currentType
*
* @return string|null
*/
private static function nextHighlightType($token, $currentType)
{
if ($token === '"') {
return self::HIGHLIGHT_STRING;
}
if (\is_array($token)) {
if ($token[0] === \T_WHITESPACE) {
return $currentType;
}
if (\array_key_exists($token[0], self::TOKEN_MAP)) {
return self::TOKEN_MAP[$token[0]];
}
}
return self::HIGHLIGHT_KEYWORD;
}
/**
* Group highlight spans into an array of lines.
*
* Optionally, restrict by start and end line numbers.
*
* @param \Generator $spans as [$spanType, $spanText] pairs
* @param int $startLine
* @param int|null $endLine
*
* @return \Generator lines, each an array of [$spanType, $spanText] pairs
*/
private static function splitLines(\Generator $spans, int $startLine = 1, ?int $endLine = null): \Generator
{
$lineNum = 1;
$buffer = [];
foreach ($spans as list($spanType, $spanText)) {
foreach (\preg_split('/(\r\n?|\n)/', $spanText) as $index => $spanLine) {
if ($index > 0) {
if ($lineNum >= $startLine) {
yield $lineNum => $buffer;
}
$lineNum++;
$buffer = [];
if ($endLine !== null && $lineNum > $endLine) {
return;
}
}
if ($spanLine !== '') {
$buffer[] = [$spanType, $spanLine];
}
}
}
if (!empty($buffer)) {
yield $lineNum => $buffer;
}
}
/**
* Format lines of highlight spans for shell output.
*
* @param \Generator $spanLines lines, each an array of [$spanType, $spanText] pairs
*
* @return \Generator Formatted lines
*/
private static function formatLines(\Generator $spanLines): \Generator
{
foreach ($spanLines as $lineNum => $spanLine) {
$line = '';
foreach ($spanLine as list($spanType, $spanText)) {
if ($spanType === self::HIGHLIGHT_DEFAULT) {
$line .= OutputFormatter::escape($spanText);
} else {
$line .= \sprintf('<%s>%s</%s>', $spanType, OutputFormatter::escape($spanText), $spanType);
}
}
yield $lineNum => $line.\PHP_EOL;
}
}
/**
* Prepend line numbers to formatted lines.
*
* Lines must be in an associative array with the correct keys in order to be numbered properly.
*
* Optionally, pass $markLine to add a line marker.
*
* @param \Generator $lines Formatted lines
* @param int|null $markLine
*
* @return \Generator Numbered, formatted lines
*/
private static function numberLines(\Generator $lines, ?int $markLine = null): \Generator
{
$lines = \iterator_to_array($lines);
// Figure out how much space to reserve for line numbers.
\end($lines);
$pad = \strlen(\key($lines));
// If $markLine is before or after our line range, don't bother reserving space for the marker.
if ($markLine !== null) {
if ($markLine > \key($lines)) {
$markLine = null;
}
\reset($lines);
if ($markLine < \key($lines)) {
$markLine = null;
}
}
foreach ($lines as $lineNum => $line) {
$mark = '';
if ($markLine !== null) {
$mark = ($markLine === $lineNum) ? self::LINE_MARKER : self::NO_LINE_MARKER;
}
yield \sprintf("%s<aside>%{$pad}s</aside>: %s", $mark, $lineNum, $line);
}
}
/**
* Check whether a Reflector instance is reflectable by this formatter.
*
* @phpstan-assert-if-true \ReflectionClass|\ReflectionFunctionAbstract $reflector
*
* @param \Reflector $reflector
*/
private static function isReflectable(\Reflector $reflector): bool
{
return ($reflector instanceof \ReflectionClass || $reflector instanceof \ReflectionFunctionAbstract) && \is_file($reflector->getFileName());
}
}

View File

@ -0,0 +1,166 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Formatter;
use Psy\Util\Docblock;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* A pretty-printer for docblocks.
*/
class DocblockFormatter implements ReflectorFormatter
{
private const VECTOR_PARAM_TEMPLATES = [
'type' => 'info',
'var' => 'strong',
];
/**
* Format a docblock.
*
* @param \Reflector $reflector
*
* @return string Formatted docblock
*/
public static function format(\Reflector $reflector): string
{
$docblock = new Docblock($reflector);
$chunks = [];
if (!empty($docblock->desc)) {
$chunks[] = '<comment>Description:</comment>';
$chunks[] = self::indent(OutputFormatter::escape($docblock->desc), ' ');
$chunks[] = '';
}
if (!empty($docblock->tags)) {
foreach ($docblock::$vectors as $name => $vector) {
if (isset($docblock->tags[$name])) {
$chunks[] = \sprintf('<comment>%s:</comment>', self::inflect($name));
$chunks[] = self::formatVector($vector, $docblock->tags[$name]);
$chunks[] = '';
}
}
$tags = self::formatTags(\array_keys($docblock::$vectors), $docblock->tags);
if (!empty($tags)) {
$chunks[] = $tags;
$chunks[] = '';
}
}
return \rtrim(\implode("\n", $chunks));
}
/**
* Format a docblock vector, for example, `@throws`, `@param`, or `@return`.
*
* @see DocBlock::$vectors
*
* @param array $vector
* @param array $lines
*/
private static function formatVector(array $vector, array $lines): string
{
$template = [' '];
foreach ($vector as $type) {
$max = 0;
foreach ($lines as $line) {
$chunk = $line[$type];
$cur = empty($chunk) ? 0 : \strlen($chunk) + 1;
if ($cur > $max) {
$max = $cur;
}
}
$template[] = self::getVectorParamTemplate($type, $max);
}
$template = \implode(' ', $template);
return \implode("\n", \array_map(function ($line) use ($template) {
$escaped = \array_map(function ($l) {
if ($l === null) {
return '';
}
return OutputFormatter::escape($l);
}, $line);
return \rtrim(\vsprintf($template, $escaped));
}, $lines));
}
/**
* Format docblock tags.
*
* @param array $skip Tags to exclude
* @param array $tags Tags to format
*
* @return string formatted tags
*/
private static function formatTags(array $skip, array $tags): string
{
$chunks = [];
foreach ($tags as $name => $values) {
if (\in_array($name, $skip)) {
continue;
}
foreach ($values as $value) {
$chunks[] = \sprintf('<comment>%s%s</comment> %s', self::inflect($name), empty($value) ? '' : ':', OutputFormatter::escape($value));
}
$chunks[] = '';
}
return \implode("\n", $chunks);
}
/**
* Get a docblock vector template.
*
* @param string $type Vector type
* @param int $max Pad width
*/
private static function getVectorParamTemplate(string $type, int $max): string
{
if (!isset(self::VECTOR_PARAM_TEMPLATES[$type])) {
return \sprintf('%%-%ds', $max);
}
return \sprintf('<%s>%%-%ds</%s>', self::VECTOR_PARAM_TEMPLATES[$type], $max, self::VECTOR_PARAM_TEMPLATES[$type]);
}
/**
* Indent a string.
*
* @param string $text String to indent
* @param string $indent (default: ' ')
*/
private static function indent(string $text, string $indent = ' '): string
{
return $indent.\str_replace("\n", "\n".$indent, $text);
}
/**
* Convert underscored or whitespace separated words into sentence case.
*
* @param string $text
*/
private static function inflect(string $text): string
{
$words = \trim(\preg_replace('/[\s_-]+/', ' ', \preg_replace('/([a-z])([A-Z])/', '$1 $2', $text)));
return \implode(' ', \array_map('ucfirst', \explode(' ', $words)));
}
}

View File

@ -0,0 +1,106 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Formatter;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
/**
* Utility for creating terminal hyperlinks (OSC 8).
*/
class LinkFormatter
{
/** @var array<string, string> */
private static $styles = [];
/**
* Set styles for formatting hyperlinks.
*
* @param array $styles Map of style name to inline style string
*/
public static function setStyles(array $styles): void
{
self::$styles = $styles;
}
/**
* Check if the current Symfony Console version supports hyperlinks.
*/
public static function supportsLinks(): bool
{
static $supports = null;
if ($supports === null) {
$supports = \method_exists(OutputFormatterStyle::class, 'setHref');
}
return $supports;
}
/**
* Wrap text in a style tag, optionally including an href.
*
* @param string $style The style name (e.g., 'class', 'function', 'info')
* @param string $text The text to wrap
* @param string|null $href Optional hyperlink
*
* @return string Formatted text with style and optional href
*/
public static function styleWithHref(string $style, string $text, ?string $href = null): string
{
$escapedText = OutputFormatter::escape($text);
if ($href !== null && self::supportsLinks()) {
$href = self::encodeHrefForOsc8($href);
$inline = self::$styles[$style] ?? '';
$combinedStyle = $inline !== '' ? \sprintf('%s;href=%s', $inline, $href) : \sprintf('href=%s', $href);
return \sprintf('<%s>%s</>', $combinedStyle, $escapedText);
}
return \sprintf('<%s>%s</%s>', $style, $escapedText, $style);
}
/**
* Get the php.net manual URL for a given item.
*
* @param string $item Function or class name
*
* @return string URL to php.net manual
*/
public static function getPhpNetUrl(string $item): string
{
// Normalize the item name for URL (lowercase, replace :: with . and _ with -)
$normalized = \str_replace('::', '.', $item);
$normalized = \str_replace('_', '-', $normalized);
$normalized = \strtolower($normalized);
return 'https://php.net/'.$normalized;
}
/**
* Encode a string for use in OSC 8 hyperlink URIs.
*
* Per OSC 8 spec, URIs must only contain bytes in the 32-126 range.
*
* @param string $str String to encode
*
* @return string URI-encoded string safe for OSC 8
*/
public static function encodeHrefForOsc8(string $str): string
{
// Encode any character outside printable ASCII range (32-126)
return \preg_replace_callback('/[^\x20-\x7E]/', function ($matches) {
return \rawurlencode($matches[0]);
}, $str);
}
}

Some files were not shown because too many files have changed in this diff Show More