refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

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

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2012-2026 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)

375
vendor/psy/psysh/bin/psysh vendored Normal file
View File

@@ -0,0 +1,375 @@
#!/usr/bin/env php
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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;
$cwdFromArg = false;
$forceTrust = false;
$forceUntrust = false;
// 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];
$cwdFromArg = true;
continue;
}
if (preg_match('/^--cwd=/', $arg)) {
$cwd = substr($arg, 6);
$cwdFromArg = true;
continue;
}
if ($arg === '--trust-project') {
$forceTrust = true;
$forceUntrust = false;
} elseif ($arg === '--no-trust-project') {
$forceUntrust = true;
$forceTrust = false;
}
}
if ($cwdFromArg) {
if (!@chdir($cwd)) {
fwrite(STDERR, 'Invalid --cwd directory: ' . $cwd . PHP_EOL);
exit(1);
}
}
// Fall back to the actual cwd, or normalize the path after chdir
if (!isset($cwd) || $cwdFromArg) {
$cwd = getcwd();
}
$cwd = str_replace('\\', '/', $cwd);
if ($cwdFromArg) {
$filtered = array();
$skipNext = false;
foreach ($argv as $arg) {
if ($skipNext) {
$skipNext = false;
continue;
}
if ($arg === '--cwd') {
$skipNext = true;
continue;
}
if (preg_match('/^--cwd=/', $arg)) {
continue;
}
$filtered[] = $arg;
}
$_SERVER['argv'] = $filtered;
$_SERVER['argc'] = count($filtered);
$argv = $filtered;
}
if (isset($_SERVER['PSYSH_TRUST_PROJECT']) && $_SERVER['PSYSH_TRUST_PROJECT'] !== '') {
$mode = strtolower(trim($_SERVER['PSYSH_TRUST_PROJECT']));
if (in_array($mode, array('true', '1'))) {
$forceTrust = true;
$forceUntrust = false;
} elseif (in_array($mode, array('false', '0'))) {
$forceUntrust = true;
$forceTrust = false;
} else {
fwrite(STDERR, 'Invalid PSYSH_TRUST_PROJECT value: ' . $_SERVER['PSYSH_TRUST_PROJECT'] . '. Expected: true, 1, false, or 0.' . PHP_EOL);
exit(1);
}
}
// Pass trust decision via env var and strip CLI flags. This allows a local
// psysh version to read the trust state while avoiding errors on older
// versions that don't understand --trust-project flags.
if ($forceTrust) {
$_SERVER['PSYSH_TRUST_PROJECT'] = 'true';
$_ENV['PSYSH_TRUST_PROJECT'] = 'true';
putenv('PSYSH_TRUST_PROJECT=true');
} elseif ($forceUntrust) {
$_SERVER['PSYSH_TRUST_PROJECT'] = 'false';
$_ENV['PSYSH_TRUST_PROJECT'] = 'false';
putenv('PSYSH_TRUST_PROJECT=false');
}
if ($forceTrust || $forceUntrust) {
$filtered = array();
foreach ($argv as $arg) {
if ($arg === '--trust-project' || $arg === '--no-trust-project') {
continue;
}
$filtered[] = $arg;
}
$_SERVER['argv'] = $filtered;
$_SERVER['argc'] = count($filtered);
$argv = $filtered;
}
$trustedRoots = array();
if (!$forceTrust) {
// Find the current config directory (matching ConfigPaths logic)
$currentConfigDir = null;
$fallbackConfigDir = null;
// Windows: %APPDATA%/PsySH takes priority
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
if (isset($_SERVER['APPDATA']) && $_SERVER['APPDATA']) {
$dir = str_replace('\\', '/', $_SERVER['APPDATA']).'/PsySH';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// XDG_CONFIG_HOME/psysh
if ($currentConfigDir === null && isset($_SERVER['XDG_CONFIG_HOME']) && $_SERVER['XDG_CONFIG_HOME']) {
$dir = rtrim(str_replace('\\', '/', $_SERVER['XDG_CONFIG_HOME']), '/').'/psysh';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
// HOME/.config/psysh (default XDG location)
if ($currentConfigDir === null && isset($_SERVER['HOME']) && $_SERVER['HOME']) {
$home = rtrim(str_replace('\\', '/', $_SERVER['HOME']), '/');
$dir = $home.'/.config/psysh';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
// legacy
if ($currentConfigDir === null) {
$dir = $home.'/.psysh';
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// Windows: HOMEDRIVE/HOMEPATH fallback
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
if (isset($_SERVER['HOMEDRIVE']) && isset($_SERVER['HOMEPATH']) && $_SERVER['HOMEDRIVE'] && $_SERVER['HOMEPATH']) {
$dir = rtrim(str_replace('\\', '/', $_SERVER['HOMEDRIVE'].'/'.$_SERVER['HOMEPATH']), '/').'/.psysh';
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// Fall back to the first candidate directory if none exist yet
if ($currentConfigDir === null) {
$currentConfigDir = $fallbackConfigDir;
}
if ($currentConfigDir !== null) {
$trustFile = $currentConfigDir.'/trusted_projects.json';
if (is_file($trustFile)) {
$contents = file_get_contents($trustFile);
if ($contents !== false && $contents !== '') {
$data = json_decode($contents, true);
if (is_array($data)) {
foreach ($data as $dir) {
if (!is_string($dir) || $dir === '') {
continue;
}
$real = realpath($dir);
if ($real !== false) {
$dir = $real;
}
$trustedRoots[] = str_replace('\\', '/', $dir);
}
}
}
}
}
}
// Composer-generated bin proxies expose `_composer_autoload_path`, which points
// at the autoloader for the *current* project invoking `vendor/bin/psysh`.
// We use this to distinguish "already running via this project's local psysh"
// from "global psysh trying to hop into some other project's local psysh".
$proxyAutoloadPath = null;
if (isset($GLOBALS['_composer_autoload_path'])
&& is_string($GLOBALS['_composer_autoload_path'])
&& $GLOBALS['_composer_autoload_path'] !== ''
) {
$proxyAutoloadPath = realpath($GLOBALS['_composer_autoload_path']);
if ($proxyAutoloadPath === false) {
$proxyAutoloadPath = str_replace('\\', '/', $GLOBALS['_composer_autoload_path']);
} else {
$proxyAutoloadPath = str_replace('\\', '/', $proxyAutoloadPath);
}
}
$isCurrentProjectAutoload = function ($projectPath) use ($proxyAutoloadPath) {
if ($proxyAutoloadPath === null) {
return false;
}
$projectAutoloadPath = realpath($projectPath.'/vendor/autoload.php');
if ($projectAutoloadPath === false) {
return false;
}
return str_replace('\\', '/', $projectAutoloadPath) === $proxyAutoloadPath;
};
$markUntrustedProject = function ($projectPath, $prettyPath) {
fwrite(STDERR, 'Skipping local PsySH at ' . $prettyPath . ' (project is untrusted). Re-run with --trust-project to allow.' . PHP_EOL);
$_SERVER['PSYSH_UNTRUSTED_PROJECT'] = $projectPath;
$_ENV['PSYSH_UNTRUSTED_PROJECT'] = $projectPath;
putenv('PSYSH_UNTRUSTED_PROJECT='.$projectPath);
};
$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')) {
$realPath = realpath($path);
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
$pathReal = realpath($path);
$binReal = realpath(__DIR__ . '/..');
$isCurrentPsysh = ($pathReal !== false && $pathReal === $binReal) || $isCurrentProjectAutoload($path);
if (!$isCurrentPsysh && !$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
$markUntrustedProject($realPath, $prettyPath);
return;
}
if (!$isCurrentPsysh) {
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)) {
$packages = array_merge(isset($cfg['packages']) ? $cfg['packages'] : array(), isset($cfg['packages-dev']) ? $cfg['packages-dev'] : array());
foreach ($packages 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')) {
$realPath = realpath($path);
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
$vendorReal = realpath($path . '/vendor');
$binVendorReal = realpath(__DIR__ . '/../../..');
$isCurrentPsysh = ($vendorReal !== false && $vendorReal === $binVendorReal) || $isCurrentProjectAutoload($path);
if (!$isCurrentPsysh && !$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
$markUntrustedProject($realPath, $prettyPath);
return;
}
if (!$isCurrentPsysh) {
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());

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"
}
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Clipboard;
use Symfony\Component\Console\Output\OutputInterface;
interface ClipboardMethod
{
public function copy(string $text, OutputInterface $output): bool;
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Clipboard;
use Symfony\Component\Console\Output\OutputInterface;
class CommandClipboardMethod implements ClipboardMethod
{
private string $command;
public function __construct(string $command)
{
$this->command = $command;
}
public function copy(string $text, OutputInterface $output): bool
{
$process = \proc_open($this->command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if ($process === false) {
return false;
}
$success = $this->writeAll($pipes[0], $text);
\fclose($pipes[0]);
// Drain stdout and stderr to prevent the child process from blocking.
\stream_get_contents($pipes[1]);
\fclose($pipes[1]);
\stream_get_contents($pipes[2]);
\fclose($pipes[2]);
return $success && \proc_close($process) === 0;
}
/**
* Write the full string to the pipe, returning false on failure.
*
* @param resource $pipe
*/
private function writeAll($pipe, string $text): bool
{
$remaining = $text;
while ($remaining !== '') {
$written = \fwrite($pipe, $remaining);
if ($written === false || $written === 0) {
return false;
}
$remaining = (string) \substr($remaining, $written);
}
return true;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Clipboard;
use Symfony\Component\Console\Output\OutputInterface;
class NullClipboardMethod implements ClipboardMethod
{
public const REASON_NONE = 'none';
public const REASON_NO_COMMAND = 'no_command';
public const REASON_NO_COMMAND_SUPPORT = 'no_command_support';
private bool $warned = false;
private bool $isSsh;
private string $reason;
public function __construct(bool $isSsh, string $reason = self::REASON_NONE)
{
$this->isSsh = $isSsh;
$this->reason = $reason;
}
public function copy(string $text, OutputInterface $output): bool
{
if ($this->warned) {
return false;
}
$this->warned = true;
if ($this->isSsh) {
$output->writeln('<error>Clipboard copy is unavailable over SSH.</error>');
$output->writeln('Set <comment>useOsc52Clipboard: true</comment> to enable OSC 52.');
$output->writeln('Only enable this on trusted systems.');
return false;
}
if ($this->reason === self::REASON_NO_COMMAND_SUPPORT) {
$output->writeln('<error>Clipboard commands are unavailable in this PHP environment.</error>');
$output->writeln('Configured <comment>clipboardCommand</comment> requires <comment>proc_open</comment>.');
} elseif ($this->reason === self::REASON_NO_COMMAND) {
$output->writeln('<error>No clipboard command was found.</error>');
$output->writeln('Set <comment>clipboardCommand</comment> to configure one.');
}
return false;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Clipboard;
use Symfony\Component\Console\Output\OutputInterface;
class Osc52ClipboardMethod implements ClipboardMethod
{
public function copy(string $text, OutputInterface $output): bool
{
$base64 = \base64_encode($text);
$osc52 = "\x1b]52;c;{$base64}\x07";
if (\getenv('TMUX')) {
$osc52 = "\x1bPtmux;\x1b".\str_replace("\x1b", "\x1b\x1b", $osc52)."\x1b\\";
}
$output->write($osc52, false, OutputInterface::OUTPUT_RAW);
return true;
}
}

View File

@@ -0,0 +1,188 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeAnalysis;
use PhpParser\Error;
use Psy\Readline\Interactive\Helper\TokenHelper;
/**
* Cached analysis data for a code buffer snapshot.
*/
class BufferAnalysis
{
private string $code;
/** @var array<int, array|string> */
private array $tokens;
/** @var array<int, array{start: int, end: int}> */
private array $tokenPositions;
/** @var array<int, mixed>|null */
private ?array $ast;
private ?Error $lastError;
/**
* @param array<int, array|string> $tokens
* @param array<int, array{start: int, end: int}> $tokenPositions
* @param array<int, mixed>|null $ast
*/
public function __construct(string $code, array $tokens, array $tokenPositions, ?array $ast, ?Error $lastError)
{
$this->code = $code;
$this->tokens = $tokens;
$this->tokenPositions = $tokenPositions;
$this->ast = $ast;
$this->lastError = $lastError;
}
/**
* Get the token_get_all() tokens for the buffer.
*
* @return array<int, array|string>
*/
public function getTokens(): array
{
return $this->tokens;
}
/**
* Get start/end code-point positions for each token.
*
* @return array<int, array{start: int, end: int}>
*/
public function getTokenPositions(): array
{
return $this->tokenPositions;
}
/**
* Get the parsed AST, or null if parsing failed.
*
* @return array<int, mixed>|null
*/
public function getAst(): ?array
{
return $this->ast;
}
/**
* Get the last parse error, if any.
*/
public function getLastError(): ?Error
{
return $this->lastError;
}
/**
* Check whether the buffer has balanced (), [] and {} pairs.
*/
public function hasBalancedBrackets(): bool
{
$stack = [];
$pairs = ['(' => ')', '[' => ']', '{' => '}'];
foreach ($this->tokens as $token) {
if (\is_array($token)) {
continue;
}
if (isset($pairs[$token])) {
$stack[] = $token;
} elseif (\in_array($token, $pairs, true)) {
if (empty($stack)) {
return false;
}
$last = \array_pop($stack);
if ($pairs[$last] !== $token) {
return false;
}
}
}
return empty($stack);
}
/**
* Check whether the buffer ends with an operator that requires more input.
*/
public function hasTrailingOperator(): bool
{
return TokenHelper::hasTrailingOperator($this->tokens);
}
/**
* Check whether the token stream ends inside a string or comment context.
*/
public function endsInOpenStringOrComment(): bool
{
if ($this->tokens === []) {
return false;
}
$last = $this->tokens[\count($this->tokens) - 1];
return $last === '"' || $last === '`' ||
(\is_array($last) && \in_array($last[0], [\T_ENCAPSED_AND_WHITESPACE, \T_START_HEREDOC, \T_COMMENT], true));
}
/**
* Check whether the buffer ends with a control structure header that still needs a body.
*/
public function hasControlStructureWithoutBody(): bool
{
$trimmed = \rtrim($this->code);
if (\preg_match('/\b(if|while|for|foreach|elseif)\s*\(.*\)\s*$/', $trimmed)) {
$lastParen = \strrpos($trimmed, ')');
if ($lastParen !== false) {
$afterParen = \trim(\substr($trimmed, $lastParen + 1));
if ($afterParen === '') {
if ($this->lastError !== null && !$this->isEOFError($this->lastError)) {
return false;
}
return true;
}
}
}
$isElseAfterBrace = \preg_match('/\}\s*else\s*$/', $trimmed);
$isBareElse = \preg_match('/^\s*else\s*$/', $trimmed);
if ($isElseAfterBrace || $isBareElse) {
if ($isBareElse && $this->lastError !== null && !$this->isEOFError($this->lastError)) {
return false;
}
return true;
}
return false;
}
/**
* Check whether the last parse error is an unexpected-EOF error.
*/
public function hasEOFError(): bool
{
return $this->lastError !== null && $this->isEOFError($this->lastError);
}
private function isEOFError(Error $error): bool
{
$msg = $error->getRawMessage();
return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);
}
}

View File

@@ -0,0 +1,117 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeAnalysis;
use PhpParser\Error;
use PhpParser\Parser;
use Psy\ParserFactory;
/**
* Maintains a cached analysis snapshot for code buffers.
*
* Uses a small LRU cache (2 entries) to avoid thrashing when callers
* alternate between full-buffer and partial (before-cursor) text.
*/
class BufferAnalyzer
{
private const CACHE_SIZE = 2;
private Parser $parser;
/**
* LRU cache of recent analyses, most-recently-used first.
*
* @var array<int, array{code: string, analysis: BufferAnalysis}>
*/
private array $cache = [];
public function __construct(?Parser $parser = null)
{
$this->parser = $parser ?? (new ParserFactory())->createParser();
}
/**
* Analyze the given code, using cached data when possible.
*/
public function analyze(string $code): BufferAnalysis
{
foreach ($this->cache as $i => $entry) {
if ($entry['code'] === $code) {
if ($i > 0) {
\array_splice($this->cache, $i, 1);
\array_unshift($this->cache, $entry);
}
return $entry['analysis'];
}
}
$analysis = $this->buildAnalysis($code);
\array_unshift($this->cache, ['code' => $code, 'analysis' => $analysis]);
if (\count($this->cache) > self::CACHE_SIZE) {
\array_pop($this->cache);
}
return $analysis;
}
/**
* Check whether appending a semicolon would make the code parseable.
*/
public function canBeFixedWithSemicolon(string $code): bool
{
try {
$this->parser->parse('<?php '.$code.";\n");
return true;
} catch (Error $e) {
return false;
}
}
private function buildAnalysis(string $code): BufferAnalysis
{
$tokens = @\token_get_all('<?php '.$code);
$tokenPositions = [];
$position = 0;
foreach ($tokens as $index => $token) {
$text = \is_array($token) ? $token[1] : $token;
$length = \mb_strlen($text);
$tokenPositions[$index] = ['start' => $position, 'end' => $position + $length];
$position += $length;
}
$ast = null;
$lastError = null;
try {
// Trailing newline improves heredoc EOF behavior to match runtime checks.
$ast = $this->parser->parse('<?php '.$code."\n");
} catch (Error $e) {
$lastError = $e;
}
return $this->createAnalysis($code, $tokens, $tokenPositions, $ast, $lastError);
}
/**
* @param string $code
* @param array<int, array|string> $tokens
* @param array<int, array{start: int, end: int}> $tokenPositions
* @param array<int, mixed>|null $ast
*/
protected function createAnalysis(string $code, array $tokens, array $tokenPositions, ?array $ast, ?Error $lastError): BufferAnalysis
{
return new BufferAnalysis($code, $tokens, $tokenPositions, $ast, $lastError);
}
}

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

@@ -0,0 +1,711 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Error as PhpParserError;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
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 = [];
private array $aliasesByTypeByNamespace = [];
/**
* 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;
$this->aliasesByTypeByNamespace[$namespaceKey][Use_::TYPE_NORMAL] = $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->aliasesByTypeByNamespace[$namespaceKey][Use_::TYPE_NORMAL] ?? $this->aliasesByNamespace[$namespaceKey] ?? [];
}
/**
* Set use statement aliases by import type for a specific namespace.
*
* @param Name|null $namespace Namespace name or Name node (null for global namespace)
* @param array $aliasesByType Map of Use_::TYPE_* constants to alias maps
*/
public function setAliasesByTypeForNamespace(?Name $namespace, array $aliasesByType): void
{
$namespaceKey = \strtolower($namespace ? $namespace->toString() : '');
$this->aliasesByTypeByNamespace[$namespaceKey] = $aliasesByType;
$this->aliasesByNamespace[$namespaceKey] = $aliasesByType[Use_::TYPE_NORMAL] ?? [];
}
/**
* Get use statement aliases by import type for a specific namespace.
*
* @param Name|string|null $namespace Namespace name or Name node (null for global namespace)
*
* @return array Map of Use_::TYPE_* constants to alias maps
*/
public function getAliasesByTypeForNamespace($namespace): array
{
$namespaceName = $namespace instanceof Name ? $namespace->toString() : $namespace;
$namespaceKey = \strtolower($namespaceName ?? '');
if (isset($this->aliasesByTypeByNamespace[$namespaceKey])) {
return $this->aliasesByTypeByNamespace[$namespaceKey];
}
if (!isset($this->aliasesByNamespace[$namespaceKey])) {
return [];
}
return [Use_::TYPE_NORMAL => $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;
}
/**
* Resolve a function name using current use statements and namespace.
*
* Returns the fully-qualified callable name, or null if the name would not
* resolve to a callable function in the current REPL scope.
*/
public function resolveFunctionName(string $name): ?string
{
if ($name === '') {
return null;
}
if ($name[0] === '\\') {
$name = \substr($name, 1);
return \function_exists($name) ? '\\'.$name : null;
}
$parts = \explode('\\', $name);
$namespace = $this->getNamespace();
$namespaceString = $namespace ? \implode('\\', $namespace) : null;
$functionAliases = $this->getAliasesByTypeForNamespace($namespaceString)[Use_::TYPE_FUNCTION] ?? [];
$firstPart = \strtolower($parts[0]);
if (isset($functionAliases[$firstPart])) {
$aliasName = $functionAliases[$firstPart];
// PHP-Parser 5.x uses getParts(), 4.x uses ->parts
$aliasParts = \method_exists($aliasName, 'getParts') ? $aliasName->getParts() : $aliasName->parts;
$resolved = \implode('\\', \array_merge($aliasParts, \array_slice($parts, 1)));
return \function_exists($resolved) ? '\\'.$resolved : null;
}
if ($namespace) {
$namespaced = \implode('\\', \array_merge($namespace, $parts));
if (\function_exists($namespaced)) {
return '\\'.$namespaced;
}
}
if (\count($parts) > 1) {
return null;
}
return \function_exists($name) ? '\\'.$name : null;
}
/**
* Resolve a direct function call from raw input in the current REPL scope.
*
* If $expectedName is provided, the input must be a direct call to that
* function name in order to be considered a collision.
*/
public function getCallableFunctionForInput(string $input, ?string $expectedName = null): ?string
{
try {
$stmts = $this->parse('<?php '.$input, false);
} catch (ParseErrorException $e) {
return null;
}
if ($stmts === false) {
return null;
}
$call = $this->getDirectFunctionCallFromStatements($stmts, $expectedName);
if ($call === null) {
return null;
}
$function = $this->resolveFunctionName($call->name->toString());
if ($function !== null && $this->functionCallMatchesArity($function, $call)) {
return $function;
}
return null;
}
/**
* Return the first direct function call expression from a statement list.
*
* @param \PhpParser\Node[] $stmts
*/
private function getDirectFunctionCallFromStatements(array $stmts, ?string $expectedName = null): ?FuncCall
{
$stmt = $stmts[0] ?? null;
if (!$stmt instanceof Expression) {
return null;
}
$expr = $stmt->expr;
if (!$expr instanceof FuncCall || !$expr->name instanceof Name) {
return null;
}
if ($expectedName !== null && \strtolower($expr->name->toString()) !== \strtolower($expectedName)) {
return null;
}
return $expr;
}
/**
* Check whether a parsed function call could satisfy the target function's arity.
*/
private function functionCallMatchesArity(string $function, FuncCall $call): bool
{
try {
$reflection = new \ReflectionFunction(\ltrim($function, '\\'));
} catch (\ReflectionException $e) {
return false;
}
// Unpacked args can satisfy any arity, so only validate fixed arg counts.
foreach ($call->args as $arg) {
if ($arg->unpack) {
return true;
}
}
$argCount = \count($call->args);
$required = $reflection->getNumberOfRequiredParameters();
$max = $reflection->isVariadic() ? \PHP_INT_MAX : $reflection->getNumberOfParameters();
return $argCount >= $required && $argCount <= $max;
}
/**
* 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 (PhpParserError $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 (PhpParserError $e) {
return false;
}
}
}
private function parseErrorIsEOF(PhpParserError $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(PhpParserError $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(PhpParserError $e, string $code): bool
{
return $e->getRawMessage() === 'Unterminated comment';
}
private function parseErrorIsTrailingComma(PhpParserError $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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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,135 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Expr\Throw_;
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_) && !self::isThrowNode($last)) {
// @codeCoverageIgnoreStart
$nodes[\count($nodes) - 1] = new Return_($last, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
// @codeCoverageIgnoreEnd
} elseif ($last instanceof Expression && !($last->expr instanceof Exit_) && !self::isThrowNode($last->expr)) {
$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) && !self::isThrowNode($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_;
}
/**
* PHP-Parser 4.x modeled standalone `throw` as Stmt\Throw_, while newer
* versions expose it as Expr\Throw_ inside Stmt\Expression.
*/
private static function isThrowNode(Node $node): bool
{
return $node instanceof Throw_ || $node->getType() === 'Stmt_Throw';
}
}

View File

@@ -0,0 +1,394 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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 = [
...\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(fn ($namespace) => \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-2026 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-2026 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-2026 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-2026 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-2026 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-2026 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,228 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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 array $aliasesByType = [];
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 = [];
$this->aliasesByType = [];
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->aliasesByType = $this->cleaner->getAliasesByTypeForNamespace($node->name);
$this->aliases = $this->aliasesByType[Use_::TYPE_NORMAL] ?? [];
} else {
$this->aliases = [];
$this->aliasesByType = [];
}
}
// Track use statements for alias resolution
if ($node instanceof Use_) {
foreach ($node->uses as $useItem) {
$this->setAliasForType(\strtolower($useItem->getAlias()), $useItem->name, $this->getUseImportType($node, $useItem));
}
}
// Track group use statements
if ($node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$this->setAliasForType(\strtolower($useItem->getAlias()), Name::concat($node->prefix, $useItem->name), $this->getUseImportType($node, $useItem));
}
}
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_) {
$this->syncCompatAliases();
// Open namespaces (like `namespace Foo;`) have kind == KIND_SEMICOLON.
if ($node->getAttribute('kind') === Namespace_::KIND_SEMICOLON || $node->getAttribute('psyshReinjected')) {
if ($this->cleaner) {
$this->cleaner->setAliasesByTypeForNamespace($node->name, $this->aliasesByType);
}
}
$this->aliases = [];
$this->aliasesByType = [];
}
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
{
$this->syncCompatAliases();
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;
}
protected function getAliasesForType(int $type): array
{
$this->syncCompatAliases();
return $this->aliasesByType[$type] ?? [];
}
private function setAliasForType(string $alias, Name $name, int $type): void
{
$this->aliasesByType[$type][$alias] = $name;
if ($type === Use_::TYPE_NORMAL) {
$this->aliases[$alias] = $name;
}
}
/**
* Sync $aliases into $aliasesByType[TYPE_NORMAL] for subclasses that read $aliases directly.
*/
private function syncCompatAliases(): void
{
if ($this->aliases === []) {
unset($this->aliasesByType[Use_::TYPE_NORMAL]);
return;
}
$this->aliasesByType[Use_::TYPE_NORMAL] = $this->aliases;
}
/**
* Resolve the import type for a use item across PHP-Parser 4.x and 5.x.
*
* Individual use items may specify their own type (e.g. in group use
* statements), otherwise fall back to the parent statement type.
*/
protected function getUseImportType(Node $node, Node $useItem): int
{
$itemType = $useItem->type ?? null;
if (\is_int($itemType) && $itemType !== Use_::TYPE_UNKNOWN) {
return $itemType;
}
$nodeType = $node->type ?? null;
if (\is_int($nodeType) && $nodeType !== Use_::TYPE_UNKNOWN) {
return $nodeType;
}
return Use_::TYPE_NORMAL;
}
}

View File

@@ -0,0 +1,119 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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->setAliasesByTypeForNamespace($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-2026 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-2026 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-2026 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-2026 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-2026 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,162 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\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')) {
$aliasesByType = $this->cleaner->getAliasesByTypeForNamespace($node->name);
if (!empty($aliasesByType)) {
$useStatements = $this->createUseStatements($aliasesByType);
$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.
$aliasesByType = $this->cleaner->getAliasesByTypeForNamespace(null);
if (!empty($aliasesByType)) {
$useStatements = $this->createUseStatements($aliasesByType);
$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->aliasesByType)) {
$this->cleaner->setAliasesByTypeForNamespace(null, $this->aliasesByType);
}
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
{
$seenAliases = [];
foreach ($stmt->uses as $useItem) {
$alias = \strtolower($useItem->getAlias());
$type = $this->getUseImportType($stmt, $useItem);
if (isset($seenAliases[$type][$alias]) || isset($this->getAliasesForType($type)[$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());
}
$seenAliases[$type][$alias] = true;
}
}
/**
* Create use statement nodes from stored aliases.
*
* @param array $aliasesByType Map of Use_::TYPE_* constants to alias maps
*
* @return Use_[] Array of use statement nodes
*/
private function createUseStatements(array $aliasesByType): array
{
$useStatements = [];
foreach ([Use_::TYPE_NORMAL, Use_::TYPE_FUNCTION, Use_::TYPE_CONSTANT] as $type) {
foreach ($aliasesByType[$type] ?? [] 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], $type, ['psyshReinjected' => true]);
}
}
return $useStatements;
}
}

View File

@@ -0,0 +1,332 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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-2026 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-2026 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-2026 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,112 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ShellOutputAdapter;
use Psy\Readline\LegacyReadline;
use Psy\Readline\Readline;
use Psy\Readline\ReadlineAware;
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 implements ReadlineAware
{
private ?Readline $readline = null;
/**
* {@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();
$shellOutput = $this->shellOutput($output);
$readline = $this->getLegacyReadline();
$legacyBuffer = $readline->getBuffer();
$shellBuffer = $shell->getPendingCodeBuffer();
$buf = $legacyBuffer !== [] ? $legacyBuffer : $shellBuffer;
if ($input->getOption('clear')) {
$readline->clearBuffer();
if ($shellBuffer !== []) {
$shell->clearPendingCodeBuffer();
}
$shellOutput->writeln($this->formatLines($buf, 'urgent'), ShellOutputAdapter::NUMBER_LINES);
} else {
$shellOutput->writeln($this->formatLines($buf), ShellOutputAdapter::NUMBER_LINES);
}
return 0;
}
/**
* Set the shell's readline implementation.
*/
public function setReadline(Readline $readline)
{
$this->readline = $readline;
}
/**
* 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(fn ($line) => \sprintf($template, $line), $lines);
}
/**
* Get the active multiline buffer from the legacy shim.
*/
private function getLegacyReadline(): LegacyReadline
{
if ($this->readline instanceof LegacyReadline) {
return $this->readline;
}
throw new \LogicException('BufferCommand requires LegacyReadline.');
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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,60 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Error as PhpParserError;
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 (PhpParserError $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 (PhpParserError $_e) {
// Throw the original error, not the semicolon one.
throw ParseErrorException::fromParseError($e);
}
}
}
}

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

@@ -0,0 +1,315 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\CodeCleanerAware;
use Psy\ContextAware;
use Psy\Output\ShellOutputAdapter;
use Psy\Readline\ReadlineAware;
use Psy\Shell;
use Psy\VarDumper\PresenterAware;
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\Input\InputInterface;
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 run(InputInterface $input, OutputInterface $output): int
{
if (
$this instanceof ContextAware ||
$this instanceof CodeCleanerAware ||
$this instanceof PresenterAware ||
$this instanceof ReadlineAware
) {
$this->getShell()->boot($input, $output);
}
return parent::run($input, $output);
}
/**
* {@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);
}
/**
* Render help text for the current input context.
*/
public function asTextForInput(InputInterface $input): string
{
return $this->asText();
}
/**
* {@inheritdoc}
*/
private function getArguments(): array
{
$hidden = $this->getHiddenArguments();
return \array_filter(
$this->getNativeDefinition()->getArguments(),
fn ($argument) => !\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(),
fn ($option) => !\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);
}
/**
* Get a ShellOutputAdapter for the given output.
*/
protected function shellOutput(OutputInterface $output): ShellOutputAdapter
{
return new ShellOutputAdapter($output);
}
}

View File

@@ -0,0 +1,562 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\Config;
use Psy\Command\Command;
use Psy\Configuration;
use Psy\Output\Theme;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* Base class for runtime configuration subcommands.
*/
abstract class AbstractConfigCommand extends Command
{
private ?Configuration $config = null;
private ?array $options = null;
public function setConfiguration(Configuration $config): void
{
$this->config = $config;
$this->options = null;
}
/**
* @return array Associative array of option definitions keyed by lowercase name
*/
protected function getOptions(): array
{
if ($this->options !== null) {
return $this->options;
}
$config = $this->getConfig();
$booleanParser = function (string $name, string $acceptedValues): callable {
return function (string $value) use ($name, $acceptedValues): bool {
switch (\strtolower($value)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
default:
throw new \InvalidArgumentException(\sprintf('Invalid %s value: %s. Accepted values: %s', $name, $value, $acceptedValues));
}
};
};
$semicolonsSuppressReturnParser = function (string $name, string $acceptedValues): callable {
return function (string $value) use ($name, $acceptedValues) {
switch (\strtolower($value)) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
case '0':
case 'false':
case 'no':
case 'off':
return false;
case Configuration::SEMICOLONS_SUPPRESS_RETURN_DOUBLE:
return Configuration::SEMICOLONS_SUPPRESS_RETURN_DOUBLE;
default:
throw new \InvalidArgumentException(\sprintf('Invalid %s value: %s. Accepted values: %s', $name, $value, $acceptedValues));
}
};
};
$enumParser = function (string $name, array $values, string $acceptedValues): callable {
return function (string $value) use ($name, $values, $acceptedValues): string {
if (!\in_array($value, $values, true)) {
throw new \InvalidArgumentException(\sprintf('Invalid %s value: %s. Accepted values: %s', $name, $value, $acceptedValues));
}
return $value;
};
};
$configEnumParser = function (string $name, array $values, string $acceptedValues): callable {
return function (string $value) use ($name, $values, $acceptedValues): string {
if (\in_array($value, $values, true)) {
return $value;
}
try {
$resolved = $this->resolveConfigurationConstant($value);
} catch (\Throwable $e) {
throw new \InvalidArgumentException(\sprintf('Invalid %s value: %s. Accepted values: %s', $name, $value, $acceptedValues), 0, $e);
}
if (!\is_string($resolved) || !\in_array($resolved, $values, true)) {
throw new \InvalidArgumentException(\sprintf('Invalid %s value: %s. Accepted values: %s', $name, $value, $acceptedValues));
}
return $resolved;
};
};
$this->options = [
'verbosity' => [
'name' => 'verbosity',
'acceptedValues' => [
Configuration::VERBOSITY_QUIET,
Configuration::VERBOSITY_NORMAL,
Configuration::VERBOSITY_VERBOSE,
Configuration::VERBOSITY_VERY_VERBOSE,
Configuration::VERBOSITY_DEBUG,
],
'parser' => $configEnumParser('verbosity', [
Configuration::VERBOSITY_QUIET,
Configuration::VERBOSITY_NORMAL,
Configuration::VERBOSITY_VERBOSE,
Configuration::VERBOSITY_VERY_VERBOSE,
Configuration::VERBOSITY_DEBUG,
], 'quiet|normal|verbose|very_verbose|debug'),
'getter' => function () use ($config): string {
return $config->verbosity();
},
'setter' => function (string $value) use ($config): void {
$config->setVerbosity($value);
},
'refresh' => true,
],
'useunicode' => [
'name' => 'useUnicode',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('useUnicode', 'on|off'),
'getter' => function () use ($config): bool {
return $config->useUnicode();
},
'setter' => function (bool $value) use ($config): void {
$config->setUseUnicode($value);
},
'refresh' => false,
],
'errorlogginglevel' => [
'name' => 'errorLoggingLevel',
'acceptedValues' => ['<php-expression>'],
'parser' => function (string $value): int {
if (\preg_match('/^\d+$/', $value)) {
return (int) $value;
}
try {
$resolved = $this->getShell()->execute($value, true);
} catch (\Throwable $e) {
throw new \InvalidArgumentException(\sprintf('Invalid errorLoggingLevel value: %s. Accepted values: <php-expression>', $value), 0, $e);
}
if (!\is_int($resolved)) {
throw new \InvalidArgumentException(\sprintf('Invalid errorLoggingLevel value: %s. Accepted values: <php-expression>', $value));
}
return $resolved;
},
'getter' => function () use ($config): string {
return $this->formatErrorLoggingLevel($config->errorLoggingLevel());
},
'setter' => function (int $value) use ($config): void {
$config->setErrorLoggingLevel($value);
},
'refresh' => false,
],
'clipboardcommand' => [
'name' => 'clipboardCommand',
'acceptedValues' => ['auto', '<command>'],
'parser' => function (string $value): ?string {
return \strtolower($value) === 'auto' ? null : $value;
},
'getter' => function () use ($config): string {
return $config->clipboardCommand() ?? 'auto';
},
'setter' => function (?string $value) use ($config): void {
$config->setClipboardCommand($value);
},
'refresh' => false,
],
'useosc52clipboard' => [
'name' => 'useOsc52Clipboard',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('useOsc52Clipboard', 'on|off'),
'getter' => function () use ($config): bool {
return $config->useOsc52Clipboard();
},
'setter' => function (bool $value) use ($config): void {
$config->setUseOsc52Clipboard($value);
},
'refresh' => false,
],
'colormode' => [
'name' => 'colorMode',
'acceptedValues' => [
Configuration::COLOR_MODE_AUTO,
Configuration::COLOR_MODE_FORCED,
Configuration::COLOR_MODE_DISABLED,
],
'parser' => $configEnumParser('colorMode', [
Configuration::COLOR_MODE_AUTO,
Configuration::COLOR_MODE_FORCED,
Configuration::COLOR_MODE_DISABLED,
], 'auto|forced|disabled'),
'getter' => function () use ($config): string {
return $config->colorMode();
},
'setter' => function (string $value) use ($config): void {
$config->setColorMode($value);
},
'refresh' => true,
],
'theme' => [
'name' => 'theme',
'acceptedValues' => Theme::BUILTIN_THEMES,
'parser' => $enumParser('theme', Theme::BUILTIN_THEMES, \implode('|', Theme::BUILTIN_THEMES)),
'getter' => function () use ($config): string {
return $config->theme()->getName() ?? 'custom';
},
'setter' => function (string $value) use ($config): bool {
$before = $config->theme();
$config->setTheme($value);
return !$before->equals($config->theme());
},
'refresh' => true,
],
'pager' => [
'name' => 'pager',
'acceptedValues' => ['default', 'off', '<command>'],
'parser' => function (string $value) {
switch (\strtolower($value)) {
case 'default':
case 'on':
case 'yes':
case 'true':
case '1':
return null;
case 'off':
case 'no':
case 'false':
case '0':
return false;
default:
return $value;
}
},
'getter' => function () use ($config): string {
$pager = $config->getPager();
if ($pager === false) {
return 'off';
}
if ($pager === null) {
return 'default';
}
if (\is_string($pager)) {
return $pager;
}
return \get_class($pager);
},
'setter' => function ($value) use ($config): void {
if ($value === null) {
$config->setDefaultPager();
return;
}
$config->setPager($value);
},
'refresh' => true,
],
'requiresemicolons' => [
'name' => 'requireSemicolons',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('requireSemicolons', 'on|off'),
'getter' => function () use ($config): bool {
return $config->requireSemicolons();
},
'setter' => function (bool $value) use ($config): void {
$config->setRequireSemicolons($value);
},
'refresh' => true,
],
'semicolonssuppressreturn' => [
'name' => 'semicolonsSuppressReturn',
'acceptedValues' => ['on', 'off', Configuration::SEMICOLONS_SUPPRESS_RETURN_DOUBLE],
'parser' => $semicolonsSuppressReturnParser('semicolonsSuppressReturn', 'on|off|double'),
'getter' => function () use ($config) {
return $config->semicolonsSuppressReturn();
},
'setter' => function ($value) use ($config): void {
$config->setSemicolonsSuppressReturn($value);
},
'refresh' => false,
],
'usebracketedpaste' => [
'name' => 'useBracketedPaste',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('useBracketedPaste', 'on|off'),
'getter' => function () use ($config): bool {
return $config->useBracketedPaste();
},
'setter' => function (bool $value) use ($config): void {
$config->setUseBracketedPaste($value);
},
'refresh' => true,
],
'usesyntaxhighlighting' => [
'name' => 'useSyntaxHighlighting',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('useSyntaxHighlighting', 'on|off'),
'getter' => function () use ($config): bool {
return $config->useSyntaxHighlighting();
},
'setter' => function (bool $value) use ($config): void {
$config->setUseSyntaxHighlighting($value);
},
'refresh' => true,
],
'usesuggestions' => [
'name' => 'useSuggestions',
'acceptedValues' => ['on', 'off'],
'parser' => $booleanParser('useSuggestions', 'on|off'),
'getter' => function () use ($config): bool {
return $config->useSuggestions();
},
'setter' => function (bool $value) use ($config): void {
$config->setUseSuggestions($value);
},
'refresh' => true,
],
];
return $this->options;
}
protected function getOption(string $key): ?array
{
return $this->getOptions()[\strtolower($key)] ?? null;
}
/**
* @return string[]
*/
protected function getOptionNames(): array
{
return \array_map(
fn (array $option): string => $option['name'],
\array_values($this->getOptions())
);
}
/**
* @param mixed $value
*/
protected function formatValue($value): string
{
if (\is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value === null) {
return 'null';
}
return (string) $value;
}
protected function formatAcceptedValues(array $option): string
{
return OutputFormatter::escape(\implode('|', $option['acceptedValues']));
}
protected function formatErrorLoggingLevel(int $value): string
{
if ($value === 0) {
return '0';
}
foreach ($this->getErrorLoggingConstants() as $name => $constantValue) {
if ($value === $constantValue) {
return $name;
}
}
$allMask = $this->getErrorLoggingAllMask();
if (($value & $allMask) === $value) {
$included = $this->formatErrorLoggingFlags($value);
$missingValue = $allMask & ~$value;
$missing = $this->formatErrorLoggingFlags($missingValue);
if ($included !== null && $missing !== null && $this->countErrorLoggingFlags($missingValue) < $this->countErrorLoggingFlags($value)) {
return 'E_ALL & ~'.$this->wrapErrorLoggingFlags($missing);
}
if ($included !== null) {
return $included;
}
}
return (string) $value;
}
protected function formatOptionName(string $name): string
{
return \sprintf('<info>%s</info>', $name);
}
/**
* @param string[] $names
*/
protected function formatOptionNames(array $names): string
{
return \implode(', ', \array_map(fn (string $name): string => $this->formatOptionName($name), $names));
}
protected function unsupportedMessage(string $key): string
{
return \sprintf('Configuration option `%s` is not runtime-configurable.', $key);
}
protected function getConfig(): Configuration
{
if ($this->config === null) {
throw new \RuntimeException('Configuration not available.');
}
return $this->config;
}
/**
* @return int[] Error logging constants keyed by name
*/
private function getErrorLoggingConstants(): array
{
$names = [
'E_ALL',
'E_ERROR',
'E_WARNING',
'E_PARSE',
'E_NOTICE',
'E_CORE_ERROR',
'E_CORE_WARNING',
'E_COMPILE_ERROR',
'E_COMPILE_WARNING',
'E_USER_ERROR',
'E_USER_WARNING',
'E_USER_NOTICE',
'E_STRICT',
'E_RECOVERABLE_ERROR',
'E_DEPRECATED',
'E_USER_DEPRECATED',
];
$constants = [];
foreach ($names as $name) {
if (\defined($name)) {
/** @var int $value */
$value = \constant($name);
$constants[$name] = $value;
}
}
return $constants;
}
/**
* @return int[] Error logging flag constants keyed by name, excluding E_ALL
*/
private function getErrorLoggingFlagConstants(): array
{
$constants = $this->getErrorLoggingConstants();
unset($constants['E_ALL']);
return $constants;
}
private function getErrorLoggingAllMask(): int
{
return \PHP_VERSION_ID < 80400 ? (\E_ALL | \E_STRICT) : \E_ALL;
}
private function formatErrorLoggingFlags(int $value): ?string
{
if ($value === 0) {
return null;
}
$parts = [];
$covered = 0;
foreach ($this->getErrorLoggingFlagConstants() as $name => $constantValue) {
if ($constantValue !== 0 && ($value & $constantValue) === $constantValue) {
$parts[] = $name;
$covered |= $constantValue;
}
}
if ($parts === [] || $covered !== $value) {
return null;
}
return \implode(' | ', $parts);
}
private function countErrorLoggingFlags(int $value): int
{
$count = 0;
foreach ($this->getErrorLoggingFlagConstants() as $constantValue) {
if ($constantValue !== 0 && ($value & $constantValue) === $constantValue) {
$count++;
}
}
return $count;
}
private function wrapErrorLoggingFlags(string $expression): string
{
return \strpos($expression, ' | ') === false ? $expression : '('.$expression.')';
}
private function resolveConfigurationConstant(string $value): string
{
if (!\preg_match('/^\\\\?(?:Psy\\\\)?Configuration::([A-Z_]+)$/', $value, $matches)) {
throw new \InvalidArgumentException('Unsupported configuration constant expression.');
}
$constant = 'Psy\\Configuration::'.$matches[1];
if (!\defined($constant)) {
throw new \InvalidArgumentException('Unknown configuration constant.');
}
$resolved = \constant($constant);
if (!\is_string($resolved)) {
throw new \InvalidArgumentException('Configuration constant does not resolve to a string value.');
}
return $resolved;
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\Config;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Print the current value for a runtime-configurable PsySH setting.
*/
class ConfigGetCommand extends AbstractConfigCommand
{
protected function configure(): void
{
$this
->setName('config-get')
->setDefinition([
new InputArgument('key', InputArgument::OPTIONAL, 'Runtime-configurable option to inspect.'),
])
->setDescription('Print the current value for one runtime-configurable PsySH setting.');
}
public function asText(): string
{
return \implode("\n", [
'<comment>Usage:</comment>',
' config get \\<key>',
'',
'<comment>Help:</comment>',
' Print the current value for one runtime-configurable PsySH setting.',
'',
'<comment>Examples:</comment>',
' <return>>>> config get verbosity</return>',
' <return>>>> config get theme</return>',
'',
'<comment>Supported Options:</comment>',
' '.$this->formatOptionNames($this->getOptionNames()),
]);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$key = $input->getArgument('key');
if ($key === null) {
throw new \InvalidArgumentException('Please specify a runtime-configurable option to inspect.');
}
$option = $this->getOption($key);
if ($option === null) {
$output->writeln(\sprintf('<error>%s</error>', $this->unsupportedMessage((string) $key)));
return 1;
}
$output->writeln($this->formatValue($option['getter']()));
return 0;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\Config;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Show runtime-configurable PsySH settings and their current values.
*/
class ConfigListCommand extends AbstractConfigCommand
{
protected function configure(): void
{
$this
->setName('config-list')
->setDescription('Show runtime-configurable PsySH settings and their current values.');
}
public function asText(): string
{
return \implode("\n", [
'<comment>Usage:</comment>',
' config list',
'',
'<comment>Help:</comment>',
' Show runtime-configurable PsySH settings and their current values.',
]);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$table = $this->getTable($output);
foreach ($this->getOptions() as $option) {
$table->addRow([
$this->formatOptionName($option['name']),
$this->formatValue($option['getter']()),
]);
}
$table->render();
return 0;
}
}

View File

@@ -0,0 +1,134 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Command\Config;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Update a runtime-configurable PsySH setting for the current session.
*/
class ConfigSetCommand extends AbstractConfigCommand
{
protected function configure(): void
{
$this
->setName('config-set')
->setDefinition([
new InputArgument('key', InputArgument::OPTIONAL, 'Runtime-configurable option to update.'),
new CodeArgument('value', CodeArgument::OPTIONAL, 'New runtime value for the selected option.'),
])
->setDescription('Update one runtime-configurable PsySH setting for the current session.');
}
public function asText(): string
{
return \implode("\n", [
'<comment>Usage:</comment>',
' config set \\<key> \\<value>',
'',
'<comment>Help:</comment>',
' Set a runtime-configurable PsySH setting for the current session.',
'',
'<comment>Examples:</comment>',
' <return>>>> config set verbosity debug</return>',
' <return>>>> config set pager off</return>',
' <return>>>> config set \\<key> --help</return>',
'',
'<comment>Supported Options:</comment>',
$this->renderSettableKeys(),
]);
}
public function asTextForInput(InputInterface $input): string
{
$key = $input->getArgument('key');
if ($key === null) {
return $this->asText();
}
$option = $this->getOption((string) $key);
if ($option === null) {
return $this->asText();
}
return \implode("\n", [
'<comment>Usage:</comment>',
\sprintf(' config set %s \\<value>', $option['name']),
'',
'<comment>Help:</comment>',
\sprintf(' Set %s for the current session.', $this->formatOptionName($option['name'])),
'',
'<comment>Accepted Values:</comment>',
' '.$this->formatAcceptedValues($option),
'',
'<comment>Current Value:</comment>',
' '.$this->formatValue($option['getter']()),
]);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$key = $input->getArgument('key');
if ($key === null) {
throw new \InvalidArgumentException('Please specify a runtime-configurable option to update.');
}
$option = $this->getOption($key);
if ($option === null) {
$output->writeln(\sprintf('<error>%s</error>', $this->unsupportedMessage((string) $key)));
return 1;
}
$rawValue = $input->getArgument('value');
if ($rawValue === null) {
throw new \InvalidArgumentException(\sprintf('Please specify a value for `%s`. Accepted values: %s', $option['name'], $this->formatAcceptedValues($option)));
}
try {
$value = $option['parser']((string) $rawValue);
$changed = $option['setter']($value);
} catch (\InvalidArgumentException $e) {
$output->writeln(\sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
if ($option['refresh'] && $changed !== false) {
$this->getShell()->applyRuntimeConfigChange($option['name']);
}
$output->writeln(\sprintf(
'<info>%s</info> = <return>%s</return>',
$option['name'],
$this->formatValue($option['getter']())
));
return 0;
}
private function renderSettableKeys(): string
{
$lines = [];
foreach ($this->getOptions() as $option) {
$lines[] = \sprintf(' %s (%s)', $this->formatOptionName($option['name']), $this->formatAcceptedValues($option));
}
return \implode("\n", $lines);
}
}

View File

@@ -0,0 +1,377 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Config\AbstractConfigCommand;
use Psy\Command\Config\ConfigGetCommand;
use Psy\Command\Config\ConfigListCommand;
use Psy\Command\Config\ConfigSetCommand;
use Psy\CommandArgumentCompletionAware;
use Psy\Completion\AnalysisResult;
use Psy\Completion\FuzzyMatcher;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Inspect and update runtime-configurable settings for the current shell session.
*/
class ConfigCommand extends AbstractConfigCommand implements CommandArgumentCompletionAware
{
private const ACTIONS = ['list', 'get', 'set'];
/** @var array{supported: bool, completions: string[]}|null */
private ?array $lastCompletionResult = null;
private string $lastCompletionInput = '';
private string $defaultHelp = '';
protected function configure(): void
{
$this->defaultHelp = \implode("\n", [
'Inspect or update runtime-configurable PsySH settings for the current session.',
'',
'e.g.',
'<return>>>> config list</return>',
'<return>>>> config get verbosity</return>',
'<return>>>> config set verbosity debug</return>',
'<return>>>> config set pager off</return>',
'<return>>>> config set clipboardCommand auto</return>',
'',
'Runtime-configurable keys include '.$this->formatOptionNames([
'verbosity',
'useUnicode',
'errorLoggingLevel',
'clipboardCommand',
'useOsc52Clipboard',
'colorMode',
'theme',
'pager',
'requireSemicolons',
'semicolonsSuppressReturn',
'useBracketedPaste',
'useSyntaxHighlighting',
'useSuggestions',
]).'.',
]);
$this
->setName('config')
->setDefinition([
new InputArgument('action', InputArgument::OPTIONAL, 'Action: list, get, or set.', 'list'),
new InputArgument('key', InputArgument::OPTIONAL, 'Runtime-configurable option to inspect or update.'),
new CodeArgument('value', CodeArgument::OPTIONAL, 'New value when using `set`.'),
])
->setDescription('Inspect or update runtime-configurable PsySH settings for the current session.')
->setHelp($this->defaultHelp);
}
public function run(InputInterface $input, OutputInterface $output): int
{
if ($input->hasParameterOption(['--help', '-h'], true)) {
$output->writeln($this->asTextForInput($input));
return 0;
}
return parent::run($input, $output);
}
public function asTextForInput(InputInterface $input): string
{
$action = $this->getActionFromInput($input);
if ($action === '') {
return $this->asText();
}
$command = $this->createChildCommand($action);
if ($command === null) {
return $this->asText();
}
return $command->asTextForInput($this->createChildInput($command, $action, $this->rawArguments($input)));
}
public function getArgumentCompletions(AnalysisResult $analysis): array
{
return $this->resolveArgumentCompletion($analysis)['completions'];
}
public function supportsArgumentCompletion(AnalysisResult $analysis): bool
{
return $this->resolveArgumentCompletion($analysis)['supported'];
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$action = \strtolower((string) $input->getArgument('action'));
$command = $this->createChildCommand($action);
if ($command === null) {
throw new \InvalidArgumentException(\sprintf('Unknown config action: %s. Expected list, get, or set.', $action));
}
return $command->run($this->createChildInput($command, $action, [
$action,
(string) $input->getArgument('key'),
(string) $input->getArgument('value'),
]), $output);
}
/**
* @param string[] $arguments
*/
private function createChildInput(Command $command, string $action, array $arguments): ArrayInput
{
$parameters = [];
switch ($action) {
case 'get':
if (isset($arguments[1]) && $arguments[1] !== '') {
$parameters['key'] = $arguments[1];
}
break;
case 'set':
if (isset($arguments[1]) && $arguments[1] !== '') {
$parameters['key'] = $arguments[1];
}
if (isset($arguments[2]) && $arguments[2] !== '') {
$parameters['value'] = $arguments[2];
}
break;
}
$input = new ArrayInput($parameters, $command->getDefinition());
$input->setInteractive(false);
return $input;
}
private function createChildCommand(string $action): ?Command
{
switch ($action) {
case '':
case 'list':
$command = new ConfigListCommand();
break;
case 'get':
$command = new ConfigGetCommand();
break;
case 'set':
$command = new ConfigSetCommand();
break;
default:
return null;
}
$command->setConfiguration($this->getConfig());
$command->setApplication($this->getApplication());
return $command;
}
private function getActionFromInput(InputInterface $input): string
{
$arguments = $this->rawArguments($input);
return \strtolower($arguments[0] ?? '');
}
/**
* Extract positional arguments from the raw input string.
*
* Symfony's Input classes don't expose raw tokens after parsing, so we
* re-tokenize __toString() output to recover them for child command routing.
*
* @return string[]
*/
private function rawArguments(InputInterface $input): array
{
if (!$input instanceof ArrayInput && !$input instanceof StringInput) {
return [];
}
return $this->tokenizeArguments($input->__toString());
}
/**
* @return array{0: string[], 1: bool}
*/
private function parseCompletionInput(string $input): array
{
$trimmed = \rtrim($input);
return [$this->tokenizeArguments($trimmed), $trimmed !== $input];
}
/**
* Tokenize an input string into positional arguments, skipping options.
*
* @return string[]
*/
private function tokenizeArguments(string $input): array
{
if ($input === '') {
return [];
}
\preg_match_all('/"[^"]*"|\'[^\']*\'|\S+/', $input, $matches);
$arguments = [];
foreach ($matches[0] as $token) {
if ($token === '--') {
break;
}
if ($token !== '' && $token[0] === '-') {
continue;
}
$arguments[] = $this->trimQuotes($token);
}
$first = $arguments[0] ?? null;
if ($first === $this->getName() || \in_array($first, $this->getAliases(), true)) {
\array_shift($arguments);
}
return $arguments;
}
/**
* @param string[] $arguments
*/
private function isCompletingSetValue(array $arguments, bool $hasTrailingSpace): bool
{
$count = \count($arguments);
if ($count < 2 || $count > 3) {
return false;
}
return ($count === 2 && $hasTrailingSpace) || ($count === 3 && !$hasTrailingSpace);
}
/**
* @return array{supported: bool, completions: string[]}
*/
private function resolveArgumentCompletion(AnalysisResult $analysis): array
{
if ($this->lastCompletionResult !== null && $this->lastCompletionInput === $analysis->input) {
return $this->lastCompletionResult;
}
$this->lastCompletionInput = $analysis->input;
return $this->lastCompletionResult = $this->doResolveArgumentCompletion($analysis->input);
}
/**
* @return array{supported: bool, completions: string[]}
*/
private function doResolveArgumentCompletion(string $input): array
{
[$arguments, $hasTrailingSpace] = $this->parseCompletionInput($input);
$count = \count($arguments);
$action = \strtolower($arguments[0] ?? '');
if ($count === 0 || ($count === 1 && !$hasTrailingSpace)) {
return ['supported' => true, 'completions' => self::ACTIONS];
}
switch ($action) {
case 'list':
return ['supported' => true, 'completions' => []];
case 'get':
case 'set':
// Completing the key name (cursor on or just after argument position 2)
if ($count <= 2 && ($count === 1 || !$hasTrailingSpace)) {
return ['supported' => true, 'completions' => $this->getOptionNames()];
}
if ($action !== 'set') {
return ['supported' => true, 'completions' => []];
}
if (!$this->isCompletingSetValue($arguments, $hasTrailingSpace)) {
return ['supported' => true, 'completions' => []];
}
return $this->resolveSetValueCompletion($arguments, $hasTrailingSpace);
default:
return ['supported' => true, 'completions' => self::ACTIONS];
}
}
/**
* @param string[] $arguments
*
* @return array{supported: bool, completions: string[]}
*/
private function resolveSetValueCompletion(array $arguments, bool $hasTrailingSpace): array
{
$key = $arguments[1];
$option = $this->getOption($key);
if ($option === null) {
return ['supported' => false, 'completions' => []];
}
$acceptsFreeForm = false;
$completions = [];
foreach ($option['acceptedValues'] as $value) {
if ($value !== '' && $value[0] === '<') {
$acceptsFreeForm = true;
} else {
$completions[] = $value;
}
}
if (!$acceptsFreeForm) {
return ['supported' => $completions !== [], 'completions' => $completions];
}
$valuePrefix = $hasTrailingSpace ? '' : ($arguments[2] ?? '');
if ($valuePrefix === '') {
return ['supported' => $completions !== [], 'completions' => $completions];
}
if (FuzzyMatcher::filter($valuePrefix, $completions) !== []) {
return ['supported' => true, 'completions' => $completions];
}
return ['supported' => false, 'completions' => []];
}
private function trimQuotes(string $token): string
{
$quote = $token[0] ?? '';
if (($quote === '"' || $quote === '\'') && \substr($token, -1) === $quote) {
return \substr($token, 1, -1);
}
return $token;
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Clipboard\ClipboardMethod;
use Psy\Clipboard\NullClipboardMethod;
use Psy\Configuration;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Copy a value to the clipboard.
*/
class CopyCommand extends ReflectingCommand
{
private ?Configuration $config = null;
/**
* Set the configuration instance.
*
* @param Configuration $config
*/
public function setConfiguration(Configuration $config)
{
$this->config = $config;
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('copy')
->setDefinition([
new CodeArgument('expression', CodeArgument::OPTIONAL, 'Expression to copy.'),
])
->setDescription('Copy a value to the clipboard.')
->setHelp(
<<<'HELP'
Copy a value to the clipboard.
When given:
- an expression, copy the exported value of the expression to the clipboard.
- no arguments, copy the last evaluated result (<info>$_</info>) to the clipboard.
e.g.
<return>>>> copy new Foo()</return>
<return>>>> copy User::all()->toArray()</return>
<return>>>> copy</return>
HELP
);
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$expression = $input->getArgument('expression');
$value = $expression === null ? $this->context->get('_') : $this->resolveCode($expression);
if (\is_object($value)) {
$this->setCommandScopeVariables(new \ReflectionObject($value));
}
if (!$this->getClipboardMethod()->copy($this->exportValue($value, $output), $output)) {
$output->writeln('<error>Unable to copy value to clipboard.</error>');
return 1;
}
$output->writeln('<info>Copied to clipboard.</info>');
return 0;
}
private function getClipboardMethod(): ClipboardMethod
{
return $this->config ? $this->config->getClipboard() : new NullClipboardMethod(false);
}
private function exportValue($value, OutputInterface $output): string
{
$export = '';
$warnings = [];
\set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool {
$warnings[$errstr] = true;
return true;
});
try {
$export = (string) \var_export($value, true);
} finally {
\restore_error_handler();
}
foreach (\array_keys($warnings) as $warning) {
if ($warning === 'var_export does not handle circular references') {
$output->writeln('<warning>Value contains circular references; copied export may be incomplete.</warning>');
break;
}
$output->writeln(\sprintf('<warning>%s</warning>', $warning));
break;
}
return $export;
}
}

View File

@@ -0,0 +1,336 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Reflection\ReflectionConstant;
use Psy\Reflection\ReflectionLanguageConstruct;
use Psy\Util\Tty;
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
{
$shellOutput = $this->shellOutput($output);
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;
$shellOutput->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');
} elseif ($doc !== null) {
$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);
}
}
}
$shellOutput->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 = Tty::getWidth();
$formatter = new ManualFormatter($width, $manual);
return $formatter->format($doc);
}
break;
}
}
return null;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\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
{
$depth = $input->getOption('depth');
$target = $this->resolveCode($input->getArgument('target'));
$this->shellOutput($output)->page($this->presenter->present($target, $depth, ($input->getOption('all') ? Presenter::VERBOSE : 0) | Presenter::RAW), OutputInterface::OUTPUT_RAW);
if (\is_object($target)) {
$this->setCommandScopeVariables(new \ReflectionObject($target));
}
return 0;
}
}

View File

@@ -0,0 +1,216 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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 Psy\Util\Tty;
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';
// Enable signal characters so Ctrl-C can interrupt the editor.
// PsySH's interactive readline disables isig at the prompt, but
// the editor needs it to handle signals properly.
$originalStty = null;
if (Tty::supportsStty()) {
$originalStty = \trim((string) @\shell_exec('stty -g 2>/dev/null'));
@\shell_exec('stty isig 2>/dev/null');
}
$pipes = [];
$proc = \proc_open("{$editor} {$escapedFilePath}", [\STDIN, \STDOUT, \STDERR], $pipes);
// Ignore SIGINT in PsySH while the editor is running. The editor
// handles ctrl-c itself; we just need to not die when the signal
// is delivered to our process group. Set this after proc_open so
// the editor inherits default signal handling.
if (\function_exists('pcntl_signal')) {
\pcntl_signal(\SIGINT, \SIG_IGN);
}
try {
\proc_close($proc);
} finally {
if (\function_exists('pcntl_signal')) {
\pcntl_signal(\SIGINT, \SIG_DFL);
}
if ($originalStty === null) {
// nothing to restore
} elseif ($originalStty === '') {
@\shell_exec('stty -isig 2>/dev/null');
} else {
@\shell_exec('stty '.\escapeshellarg($originalStty).' 2>/dev/null');
}
}
$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-2026 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,188 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ManualWrapper;
use Psy\Readline\Interactive\Layout\DisplayString;
use Psy\Util\Tty;
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 const TABLE_OVERHEAD_TWO_COLUMNS = 7;
private const TABLE_OVERHEAD_THREE_COLUMNS = 10;
private const MIN_DESCRIPTION_WIDTH_FOR_ALIAS_COLUMN = 40;
private ?Command $command = null;
private ?InputInterface $commandInput = 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;
}
/**
* Helper for preserving the original input when rendering contextual help.
*/
public function setCommandInput(InputInterface $input): void
{
$this->commandInput = $input;
}
/**
* {@inheritdoc}
*
* @return int 0 if everything went fine, or an exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shellOutput = $this->shellOutput($output);
if ($this->command !== null) {
// help for an individual command
$shellOutput->page($this->command->asTextForInput($this->commandInput ?? $input));
$this->command = null;
$this->commandInput = 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;
}
if (!$cmd instanceof Command) {
throw new \RuntimeException(\sprintf('Expected Psy\Command\Command instance, got %s', \get_class($cmd)));
}
$shellOutput->page($cmd->asTextForInput($input));
} else {
$this->commandInput = null;
$shellOutput->page(function (OutputInterface $pagedOutput): void {
$this->renderCommandList($pagedOutput);
});
}
return 0;
}
/**
* Render the top-level command list with fixed command widths and a
* conditional alias column when the terminal is wide enough.
*/
private function renderCommandList(OutputInterface $output): void
{
$commands = [];
foreach ($this->getApplication()->all() as $name => $command) {
if ($name !== $command->getName()) {
continue;
}
$commands[] = [
'name' => $name,
'description' => $command->getDescription(),
'aliasText' => $command->getAliases()
? \sprintf('<comment>Aliases:</comment> %s', \implode(', ', $command->getAliases()))
: '',
];
}
$nameWidth = 0;
$aliasWidth = 0;
$descriptionWidth = 0;
$formatter = $output->getFormatter();
foreach ($commands as $command) {
$nameWidth = \max($nameWidth, DisplayString::width($command['name']));
$aliasWidth = \max($aliasWidth, DisplayString::widthWithoutFormatting($command['aliasText'], $formatter));
$descriptionWidth = \max($descriptionWidth, DisplayString::width($command['description']));
}
$terminalWidth = Tty::getWidth();
$wrapper = new ManualWrapper();
$table = $this->getTable($output)->setColumnWidth(0, $nameWidth);
$descriptionWidthWithAliasColumn = $terminalWidth - $nameWidth - $aliasWidth - self::TABLE_OVERHEAD_THREE_COLUMNS;
if ($aliasWidth > 0 && $descriptionWidthWithAliasColumn >= self::MIN_DESCRIPTION_WIDTH_FOR_ALIAS_COLUMN) {
$descriptionColumnWidth = \min($descriptionWidth, $descriptionWidthWithAliasColumn);
$table
->setColumnWidth(1, $descriptionColumnWidth)
->setColumnWidth(2, $aliasWidth);
foreach ($commands as $command) {
$table->addRow([
\sprintf('<info>%s</info>', $command['name']),
$wrapper->wrap($command['description'], $descriptionColumnWidth),
$command['aliasText'],
]);
}
$table->render();
return;
}
$detailsWidth = \max(10, $terminalWidth - $nameWidth - self::TABLE_OVERHEAD_TWO_COLUMNS);
$table->setColumnWidth(1, $detailsWidth);
foreach ($commands as $command) {
$details = $wrapper->wrap($command['description'], $detailsWidth);
if ($command['aliasText'] !== '') {
$details .= "\n".$wrapper->wrap($command['aliasText'], $detailsWidth);
}
$table->addRow([
\sprintf('<info>%s</info>', $command['name']),
$details,
]);
}
$table->render();
}
}

View File

@@ -0,0 +1,270 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ShellOutputAdapter;
use Psy\Readline\Readline;
use Psy\Readline\ReadlineAware;
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 implements ReadlineAware
{
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 : ShellOutputAdapter::NUMBER_LINES;
if (!$highlighted) {
$type = $type | OutputInterface::OUTPUT_RAW;
}
$this->shellOutput($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,287 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\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();
$shellOutput = $this->shellOutput($output);
$method = $input->getOption('long') ? 'writeLong' : 'write';
if ($target = $input->getArgument('target')) {
list($target, $reflector) = $this->getTargetAndReflector($target, $output);
} else {
$reflector = null;
}
if ($input->getOption('long')) {
$shellOutput->startPaging();
}
foreach ($this->enumerators as $enumerator) {
$this->$method($output, $enumerator->enumerate($input, $reflector, $target));
}
if ($input->getOption('long')) {
$shellOutput->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(fn ($item) => $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);
$first = true;
foreach ($result as $label => $items) {
if (!$first) {
$output->writeln('');
}
$output->writeln(\sprintf('<strong>%s:</strong>', $label));
$table->setRows([]);
foreach ($items as $item) {
$table->addRow([$this->formatItemName($item), $item['value']]);
}
$table->render();
$first = false;
}
}
/**
* 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-2026 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,130 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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, fn ($class) => $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-2026 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,114 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Formatter\OutputFormatter;
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';
const IS_VIRTUAL = 'virtual';
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)
{
// Symfony VarDumper 5.4 trips over NAN/INF on PHP 8.5 in PHAR builds,
// so format non-finite floats directly instead of cloning them.
if (\is_float($value) && !\is_finite($value)) {
return OutputFormatter::escape(\sprintf('<float>%s</float>', \var_export($value, true)));
}
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-2026 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-2026 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,160 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ReflectionMagicMethod;
use Psy\Util\Docblock;
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 \ReflectionMethod[]
*/
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;
}
}
// Add magic methods from docblock @method tags
foreach (Docblock::getMagicMethods($reflector) as $method) {
if ($noInherit && $method->getDeclaringClass()->getName() !== $className) {
continue;
}
// Skip if a real method with this name already exists
if (!isset($methods[$method->getName()])) {
$methods[$method->getName()] = $method;
}
}
\ksort($methods, \SORT_NATURAL | \SORT_FLAG_CASE);
return $methods;
}
/**
* Prepare formatted method array.
*
* @param \ReflectionMethod[] $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|ReflectionMagicMethod $method
*/
private function getVisibilityStyle(\Reflector $method): string
{
if ($method instanceof ReflectionMagicMethod) {
return self::IS_VIRTUAL;
}
if ($method->isPublic()) {
return self::IS_PUBLIC;
} elseif ($method->isProtected()) {
return self::IS_PROTECTED;
} else {
return self::IS_PRIVATE;
}
}
}

View File

@@ -0,0 +1,201 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ReflectionMagicProperty;
use Psy\Util\Docblock;
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 \ReflectionProperty[]
*/
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;
}
}
// Add magic properties from docblock @property tags
foreach (Docblock::getMagicProperties($reflector) as $property) {
if ($noInherit && $property->getDeclaringClass()->getName() !== $className) {
continue;
}
// Skip if a real property with this name already exists
if (!isset($properties[$property->getName()])) {
$properties[$property->getName()] = $property;
}
}
\ksort($properties, \SORT_NATURAL | \SORT_FLAG_CASE);
return $properties;
}
/**
* Prepare formatted property array.
*
* @param \ReflectionProperty[] $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|ReflectionMagicProperty $property
*/
private function getVisibilityStyle(\Reflector $property): string
{
if ($property instanceof ReflectionMagicProperty) {
return self::IS_VIRTUAL;
}
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|ReflectionMagicProperty $property
* @param mixed $target
*/
protected function presentValue(\Reflector $property, $target): string
{
// Magic properties use SignatureFormatter for display
if ($property instanceof ReflectionMagicProperty) {
return $this->presentSignature($property);
}
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-2026 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,142 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Error as PhpParserError;
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 (PhpParserError $e) {
if ($this->parseErrorIsEOF($e)) {
$nodes = $this->parser->parse($code.';');
} else {
throw $e;
}
}
$this->shellOutput($output)->page($this->presenter->present($nodes, $depth, Presenter::RAW), OutputInterface::OUTPUT_RAW);
$this->context->setReturnValue($nodes);
return 0;
}
private function parseErrorIsEOF(PhpParserError $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-2026 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-2026 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,298 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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)
{
$shellOutput = $this->shellOutput($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),
]);
}
$shellOutput->page(CodeFormatter::formatCode($code));
return;
} else {
throw $e;
}
}
// Set some magic local variables
$this->setCommandScopeVariables($reflector);
try {
$shellOutput->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;
$shell = $this->getShell();
$shell->writeExceptionHeader($output, $exception);
$shell->writeSeparator($output);
$this->writeTraceLine($output, $trace, $index);
$shell->writeSpacer($output);
$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,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Readline\ReadlineAware;
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 implements ReadlineAware
{
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,124 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Variable;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Scalar\String_;
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'));
$exception = new New_(new FullyQualifiedName(ThrowUpException::class), $args);
$throwCode = 'throw '.$this->printer->prettyPrintExpr($exception).';';
$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,176 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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 (int on 64-bit, float on 32-bit overflow)
/** @var int|float|null */
private static $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-2026 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-2026 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\ShellOutputAdapter;
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'));
$this->shellOutput($output)->page($trace, ShellOutputAdapter::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,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\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
{
$shellOutput = $this->shellOutput($output);
$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;
}
$shellOutput->startPaging();
$output->writeln(\sprintf('From <info>%s:%s</info>:', ConfigPaths::prettyPath($info['file']), $lineNum));
$output->write(CodeFormatter::formatCode($code, $startLine, $endLine, $lineNum), false);
$shellOutput->stopPaging();
return 0;
}
}

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\ShellOutputAdapter;
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);
$shellOutput = $this->shellOutput($output);
$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));
$shell = $this->getShell();
$shellOutput->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);
$shell->writeExceptionHeader($output, $exception);
$shell->writeSeparator($output);
$shellOutput->write($trace, true, ShellOutputAdapter::NUMBER_LINES);
if ($moreLines > 0) {
$shell->writeSpacer($output);
$output->writeln(\sprintf(
'<aside>Use <return>wtf -a</return> to see %d more lines</aside>',
$moreLines
));
}
$previous = $exception->getPrevious();
if ($previous !== null) {
$shell->writeSpacer($output);
}
} while ($exception = $previous);
$shellOutput->stopPaging();
return 0;
}
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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 Psy\Readline\ReadlineAware;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Execute code while bypassing reloader safety checks.
*/
class YoloCommand extends Command implements ReadlineAware
{
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);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Completion\AnalysisResult;
/**
* Interface for commands that provide positional argument completion.
*/
interface CommandArgumentCompletionAware
{
/**
* Whether this command owns completion for the current argument context.
*/
public function supportsArgumentCompletion(AnalysisResult $analysis): bool;
/**
* Return completion candidates for the current command-tail context.
*
* @return string[]
*/
public function getArgumentCompletions(AnalysisResult $analysis): array;
}

30
vendor/psy/psysh/src/CommandAware.php vendored Normal file
View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Command\Command;
/**
* CommandAware interface.
*
* This interface is used to keep completion sources and matchers up to date
* when commands are added to the Shell.
*/
interface CommandAware
{
/**
* Set the available commands.
*
* @param Command[] $commands
*/
public function setCommands(array $commands);
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 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\Command\Command;
/**
* Trait for building a command lookup map (name + aliases → Command).
*
* Used by completion sources and refiners that need to resolve a command
* name or alias to its Command instance.
*/
trait CommandMapTrait
{
/** @var array<string, Command> */
private array $commandMap = [];
/**
* Set the available commands.
*
* @param Command[] $commands
*/
public function setCommands(array $commands): void
{
$this->commandMap = [];
foreach ($commands as $command) {
$this->commandMap[$command->getName()] = $command;
foreach ($command->getAliases() as $alias) {
$this->commandMap[$alias] = $command;
}
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
use PhpParser\Node;
/**
* Completion analysis result.
*
* Shared state for the completion pipeline.
*
* The analyzer establishes the initial context, refiners may narrow it, and
* later stages reuse the same request metadata and parse-derived hints.
*/
class AnalysisResult
{
public int $kinds;
public string $prefix;
public ?string $leftSide;
public ?Node $leftSideNode;
/** @var string[] Fully-qualified class names (supports union types) */
public array $leftSideTypes;
public $leftSideValue;
public string $input;
/** @var array Tokenized input */
public array $tokens;
/** @var array Raw readline callback metadata, if available */
public array $readlineInfo;
/** @var bool Whether php-parser successfully parsed the input */
public bool $parseSucceeded;
/**
* @param string|string[]|null $leftSideTypes
*/
public function __construct(
int $kinds,
string $prefix = '',
?string $leftSide = null,
$leftSideTypes = [],
$leftSideValue = null,
array $tokens = [],
string $input = '',
?Node $leftSideNode = null,
array $readlineInfo = [],
bool $parseSucceeded = false
) {
$this->kinds = $kinds;
$this->prefix = $prefix;
$this->leftSide = $leftSide;
$this->leftSideNode = $leftSideNode;
$this->leftSideTypes = (array) $leftSideTypes;
$this->leftSideValue = $leftSideValue;
$this->tokens = $tokens;
$this->input = $input;
$this->readlineInfo = $readlineInfo;
$this->parseSucceeded = $parseSucceeded;
}
/**
* Return a copy with updated completion context for a later pipeline stage.
*/
public function withContext(int $kinds, string $prefix = '', ?string $leftSide = null, ?Node $leftSideNode = null): self
{
// Types and value are cleared because CompletionEngine re-resolves
// them after all refiners have run.
$copy = clone $this;
$copy->kinds = $kinds;
$copy->prefix = $prefix;
$copy->leftSide = $leftSide;
$copy->leftSideNode = $leftSideNode;
$copy->leftSideTypes = [];
$copy->leftSideValue = null;
return $copy;
}
}

View File

@@ -0,0 +1,218 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
use PhpParser\Node as AstNode;
use Psy\CodeCleaner;
use Psy\Completion\Refiner\AnalysisRefinerInterface;
use Psy\Completion\Refiner\CommandSyntaxRefiner;
use Psy\Completion\Refiner\PartialInputRefiner;
use Psy\Completion\Source\CatalogSource;
use Psy\Completion\Source\ClassConstantSource;
use Psy\Completion\Source\KeywordSource;
use Psy\Completion\Source\MagicMethodSource;
use Psy\Completion\Source\MagicPropertySource;
use Psy\Completion\Source\MethodSource;
use Psy\Completion\Source\NamespaceSource;
use Psy\Completion\Source\ObjectMethodSource;
use Psy\Completion\Source\ObjectPropertySource;
use Psy\Completion\Source\PropertySource;
use Psy\Completion\Source\SourceInterface;
use Psy\Completion\Source\StaticMethodSource;
use Psy\Completion\Source\StaticPropertySource;
use Psy\Completion\Source\VariableSource;
use Psy\Context;
use Psy\Readline\Interactive\Helper\DebugLog;
/**
* Completion pipeline coordinator.
*
* Each stage focuses on one job: parse the input, refine the context, then
* collect candidates from applicable sources.
*/
class CompletionEngine
{
private Context $context;
private ContextAnalyzer $analyzer;
private TypeResolver $typeResolver;
private SymbolCatalog $symbolCatalog;
/** @var SourceInterface[] */
private array $sources = [];
/** @var AnalysisRefinerInterface[] */
private array $refiners = [];
public function __construct(Context $context, ?CodeCleaner $cleaner = null, ?SymbolCatalog $symbolCatalog = null)
{
$this->context = $context;
$this->analyzer = new ContextAnalyzer($cleaner);
$this->typeResolver = new TypeResolver($context, $cleaner);
$this->symbolCatalog = $symbolCatalog ?? new SymbolCatalog();
$this->addRefiner(new PartialInputRefiner());
$this->addRefiner(new CommandSyntaxRefiner());
}
/**
* Register the standard PsySH completion source set.
*
* @param SourceInterface[] $additionalSources Pre-initialized sources to include
*/
public function registerDefaultSources(array $additionalSources = []): void
{
// Context-aware sources.
$this->addSource(new VariableSource($this->context));
$this->addSource(new ObjectMethodSource());
$this->addSource(new ObjectPropertySource());
// Static reflection sources.
$this->addSource(new MethodSource());
$this->addSource(new PropertySource());
$this->addSource(new StaticMethodSource());
$this->addSource(new StaticPropertySource());
$this->addSource(new ClassConstantSource());
// Docblock magic sources.
$this->addSource(new MagicMethodSource());
$this->addSource(new MagicPropertySource());
// Symbol sources (shared symbol catalog snapshot cache).
$this->addSource(new CatalogSource(CompletionKind::CLASS_NAME, [$this->symbolCatalog, 'getClasses'], $this->symbolCatalog));
$this->addSource(new CatalogSource(CompletionKind::INTERFACE_NAME, [$this->symbolCatalog, 'getInterfaces'], $this->symbolCatalog));
$this->addSource(new CatalogSource(CompletionKind::TRAIT_NAME, [$this->symbolCatalog, 'getTraits'], $this->symbolCatalog));
$this->addSource(new CatalogSource(CompletionKind::ATTRIBUTE_NAME, [$this->symbolCatalog, 'getAttributeClasses'], $this->symbolCatalog));
$this->addSource(new CatalogSource(CompletionKind::FUNCTION_NAME, [$this->symbolCatalog, 'getFunctions'], $this->symbolCatalog));
$this->addSource(new CatalogSource(CompletionKind::CONSTANT, [$this->symbolCatalog, 'getConstants'], $this->symbolCatalog));
$this->addSource(new NamespaceSource($this->symbolCatalog));
// Additional pre-initialized sources.
foreach ($additionalSources as $source) {
$this->addSource($source);
}
// Generic sources.
$this->addSource(new KeywordSource());
}
/**
* Add a completion source.
*/
public function addSource(SourceInterface $source): void
{
if (!\in_array($source, $this->sources, true)) {
$this->sources[] = $source;
}
}
/**
* Add an analysis refiner.
*
* Refiners translate broad parser output into the narrower completion lanes
* that sources consume.
*/
public function addRefiner(AnalysisRefinerInterface $refiner): void
{
if (!\in_array($refiner, $this->refiners, true)) {
$this->refiners[] = $refiner;
}
}
/**
* Get completions for a normalized request.
*
* @return string[]
*/
public function getCompletions(CompletionRequest $request): array
{
$start = \microtime(true);
DebugLog::log('Completion', 'START', [
'mode' => $request->getMode(),
'input' => $request->getBuffer(),
'cursor' => $request->getCursor(),
]);
$analysis = $this->analyzer->analyze(
$request->getBuffer(),
$request->getCursor(),
$request->getReadlineInfo()
);
foreach ($this->refiners as $refiner) {
$analysis = $refiner->refine($analysis);
}
DebugLog::log('Completion', 'ANALYZED', [
'kinds' => $analysis->kinds,
'prefix' => $analysis->prefix,
'leftSide' => $analysis->leftSide ?? 'null',
]);
$leftSide = $analysis->leftSide;
if ($leftSide !== null) {
$leftSideNode = $analysis->leftSideNode;
$analysis->leftSideTypes = $leftSideNode instanceof AstNode
? $this->typeResolver->resolveNodeTypes($leftSideNode, $leftSide)
: $this->typeResolver->resolveTypes($leftSide);
$analysis->leftSideValue = $this->typeResolver->resolveValue($leftSide);
DebugLog::log('Completion', 'RESOLVED_TYPES', [
'types' => empty($analysis->leftSideTypes) ? 'none' : \implode('|', $analysis->leftSideTypes),
'has_value' => $analysis->leftSideValue !== null,
]);
}
$results = $this->collectFromSources($analysis);
$results = \array_values(\array_unique(\array_filter($results, fn ($match) => $match !== '' && $match !== null)));
if ($analysis->prefix !== '' && !empty($results)) {
$before = \count($results);
$results = FuzzyMatcher::filter($analysis->prefix, $results);
DebugLog::log('Completion', 'FUZZY_FILTER', [
'before' => $before,
'after' => \count($results),
]);
}
$latencyMs = (\microtime(true) - $start) * 1000;
DebugLog::log('Completion', 'METRICS', [
'latency_ms' => \round($latencyMs, 2),
'results' => \count($results),
]);
return $results;
}
/**
* Collect completions from all applicable sources.
*
* @return string[]
*/
private function collectFromSources(AnalysisResult $analysis): array
{
$completions = [];
foreach ($this->sources as $source) {
if (!$source->appliesToKind($analysis->kinds)) {
continue;
}
$sourceCompletions = $source->getCompletions($analysis);
if (!empty($sourceCompletions)) {
DebugLog::log('Completion', 'SOURCE_MATCHED', [
'source' => \get_class($source),
'count' => \count($sourceCompletions),
]);
$completions = \array_merge($completions, $sourceCompletions);
}
}
return $completions;
}
}

View File

@@ -0,0 +1,75 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
/**
* Completion kind bitmask constants.
*
* Defines the syntactic kinds where completion is being requested.
* Multiple kinds can be combined using bitwise OR to represent
* positions where multiple types of completions are valid.
*
* Example:
* $kinds = CompletionKind::CLASS_NAME | CompletionKind::FUNCTION_NAME;
*/
class CompletionKind
{
// Special
public const NONE = 0;
public const UNKNOWN = 1 << 0;
// Variables
public const VARIABLE = 1 << 1; // $foo|
// Object members (instance)
public const OBJECT_METHOD = 1 << 2; // $foo->method()|
public const OBJECT_PROPERTY = 1 << 3; // $foo->property|
// Static members (class)
public const STATIC_METHOD = 1 << 4; // Foo::method()|
public const STATIC_PROPERTY = 1 << 5; // Foo::$property|
public const CLASS_CONSTANT = 1 << 6; // Foo::CONSTANT|
// Type names
public const CLASS_NAME = 1 << 7; // new Foo|, extends Foo|
public const INTERFACE_NAME = 1 << 8; // implements Bar|
public const TRAIT_NAME = 1 << 9; // use SomeTrait| (inside class)
public const ATTRIBUTE_NAME = 1 << 10; // #[Deprecated|] (PHP 8+)
// Global symbols
public const FUNCTION_NAME = 1 << 11; // foo|()
public const CONSTANT = 1 << 12; // CONST|
public const NAMESPACE = 1 << 13; // namespace Foo\|, use Foo\|
// PsySH-specific
public const COMMAND = 1 << 14; // ls|, doc|
public const COMMAND_OPTION = 1 << 15; // ls --option|, ls -a|
// PHP keywords
public const KEYWORD = 1 << 16; // echo|, isset|
// Advanced (future)
public const NAMED_PARAMETER = 1 << 17; // foo(name: |) (PHP 8+)
public const ARRAY_KEY = 1 << 18; // $array['key'|]
public const COMMAND_ARGUMENT = 1 << 19; // config set verbosity|
// Common combinations for ambiguous contexts
public const OBJECT_MEMBER = self::OBJECT_METHOD | self::OBJECT_PROPERTY; // $foo->|
public const STATIC_MEMBER = self::STATIC_METHOD | self::STATIC_PROPERTY | self::CLASS_CONSTANT; // Foo::|
public const TYPE_NAME = self::CLASS_NAME | self::INTERFACE_NAME; // Type hints, return types
public const CLASS_LIKE = self::CLASS_NAME | self::INTERFACE_NAME | self::TRAIT_NAME; // Any class-like structure
public const CALLABLE = self::FUNCTION_NAME | self::CLASS_NAME;
public const SYMBOL = self::FUNCTION_NAME | self::CLASS_LIKE | self::CONSTANT;
// Contexts where the input might be a shell command rather than PHP code
public const COMMAND_ELIGIBLE = self::UNKNOWN | self::SYMBOL | self::KEYWORD;
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
/**
* Normalized completion request.
*
* Uses the full buffer and an absolute cursor position so callers can request
* completions from any position, including multiline input.
*/
class CompletionRequest
{
public const MODE_TAB = 'tab';
public const MODE_SUGGESTION = 'suggestion';
private string $buffer;
private int $cursor;
private string $mode;
/** @var array Raw callback metadata from the caller, if any */
private array $readlineInfo;
public function __construct(string $buffer, int $cursor, string $mode = self::MODE_TAB, array $readlineInfo = [])
{
$this->buffer = $buffer;
$this->cursor = $this->normalizeCursor($buffer, $cursor);
$this->mode = $mode;
$this->readlineInfo = $readlineInfo;
}
public function getBuffer(): string
{
return $this->buffer;
}
public function getCursor(): int
{
return $this->cursor;
}
public function getMode(): string
{
return $this->mode;
}
/**
* Get raw readline callback metadata associated with this request.
*/
public function getReadlineInfo(): array
{
return $this->readlineInfo;
}
private function normalizeCursor(string $buffer, int $cursor): int
{
$length = \mb_strlen($buffer);
return \max(0, \min($cursor, $length));
}
}

View File

@@ -0,0 +1,221 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
use PhpParser\Error as PhpParserError;
use PhpParser\Node;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\NullsafeMethodCall;
use PhpParser\Node\Expr\NullsafePropertyFetch;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\StaticPropertyFetch;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\NodeTraverser;
use PhpParser\Parser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\CodeCleaner;
use Psy\ParserFactory;
/**
* Analyzes input to determine completion context.
*
* This stage provides the parser-derived starting point for completion. Its
* job is to describe the PHP syntax at the cursor, not to decide every higher-
* level completion mode built on top of that syntax.
*/
class ContextAnalyzer
{
private Parser $parser;
private Printer $printer;
private ?CodeCleaner $cleaner;
public function __construct(?CodeCleaner $cleaner = null)
{
$this->parser = (new ParserFactory())->createParser();
$this->printer = new Printer();
$this->cleaner = $cleaner;
}
/**
* Analyze input and return the coarse parser-derived context.
*/
public function analyze(string $input, int $cursor, array $readlineInfo = []): AnalysisResult
{
// Cursor is in code-point units, so use mb_substr
$inputToCursor = \mb_substr($input, 0, $cursor);
$cursorAtEnd = ($cursor >= \mb_strlen($input));
$analysis = $this->tryParse($inputToCursor, $cursorAtEnd);
$parseSucceeded = $analysis !== null;
$analysis = $analysis ?? new AnalysisResult(CompletionKind::UNKNOWN, '');
$analysis->parseSucceeded = $parseSucceeded;
$analysis->input = $inputToCursor;
$analysis->tokens = @\token_get_all('<?php '.$inputToCursor);
$analysis->readlineInfo = $readlineInfo;
return $analysis;
}
/**
* Try to parse input and analyze the resulting AST.
*/
private function tryParse(string $input, bool $cursorAtEnd): ?AnalysisResult
{
$code = '<?php '.$input;
// Try with semicolon first (most common case), then without
try {
$stmts = $this->parser->parse($code.';');
} catch (PhpParserError $e) {
try {
$stmts = $this->parser->parse($code);
} catch (PhpParserError $e2) {
return null;
}
}
if (empty($stmts)) {
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
$visitor = new DeepestNodeVisitor();
$traverser = new NodeTraverser();
$traverser->addVisitor($visitor);
$traverser->traverse($stmts);
$node = $visitor->getDeepestNode();
if ($node === null) {
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
// Trailing whitespace means the user has moved past this token
if ($cursorAtEnd && $input !== \rtrim($input)) {
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
return $this->analyzeNode($node);
}
/**
* Analyze a specific AST node to determine completion context.
*/
private function analyzeNode(Node $node): AnalysisResult
{
// $foo->bar, $foo->bar(), $foo?->bar, $foo?->bar()
if (
$node instanceof MethodCall
|| $node instanceof PropertyFetch
|| $node instanceof NullsafeMethodCall
|| $node instanceof NullsafePropertyFetch
) {
$leftSide = $this->extractExpression($node->var);
$prefix = $node->name instanceof Identifier ? $node->name->name : '';
$result = new AnalysisResult(CompletionKind::OBJECT_MEMBER, $prefix, $leftSide);
$result->leftSideNode = $node->var;
return $result;
}
// Foo::bar(), Foo::BAR, Foo::$bar
if (
$node instanceof StaticCall
|| $node instanceof ClassConstFetch
|| $node instanceof StaticPropertyFetch
) {
$leftSide = $this->extractExpression($node->class);
$prefix = $node->name instanceof Identifier ? $node->name->name : '';
$result = new AnalysisResult(CompletionKind::STATIC_MEMBER, $prefix, $leftSide);
$result->leftSideNode = $node->class;
return $result;
}
// new Foo
if ($node instanceof New_) {
$prefix = $node->class instanceof Name ? $node->class->toString() : '';
return new AnalysisResult(CompletionKind::CLASS_NAME, $prefix);
}
// $foo
if ($node instanceof Variable) {
$prefix = \is_string($node->name) ? $node->name : '';
return new AnalysisResult(CompletionKind::VARIABLE, $prefix);
}
// foo()
if ($node instanceof FuncCall && $node->name instanceof Name) {
return new AnalysisResult(CompletionKind::FUNCTION_NAME, $node->name->toString());
}
// FOO (could be a constant, function, or class reference)
if ($node instanceof ConstFetch && $node->name instanceof Name) {
return new AnalysisResult(CompletionKind::SYMBOL, $node->name->toString());
}
if ($node instanceof Identifier) {
return new AnalysisResult(CompletionKind::UNKNOWN, $node->name);
}
// Bare name: foo or Foo\Bar
if ($node instanceof Name) {
return new AnalysisResult(CompletionKind::SYMBOL, $node->toString());
}
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
/**
* Extract a string representation of an expression.
*
* Uses the printer to convert the AST node back to code.
*/
private function extractExpression($expr): string
{
if ($expr instanceof Variable && \is_string($expr->name)) {
return '$'.$expr->name;
}
if ($expr instanceof Name) {
return $this->resolveClassName($expr);
}
if ($expr instanceof Node\Expr) {
return $this->printer->prettyPrintExpr($expr);
}
return '';
}
/**
* Resolve a class name using CodeCleaner's namespace context.
*
* This ensures we use the same use statements and namespace as the code
* being executed.
*/
private function resolveClassName(Name $name): string
{
if ($this->cleaner === null) {
return $name->toString();
}
return $this->cleaner->resolveClassName($name->toString());
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
use PhpParser\Node;
use PhpParser\Node\Stmt\Expression;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
/**
* Visitor to find the top-level expression node in the statement.
*
* We want the outermost expression (PropertyFetch, MethodCall, New_, etc.)
* not their child nodes. For a statement like `$baz->format;`, we want the
* PropertyFetch node, not the Variable node inside it.
*/
class DeepestNodeVisitor extends NodeVisitorAbstract
{
private ?Node $targetNode = null;
public function enterNode(Node $node)
{
// If this is an Expression statement, grab its expression
if ($node instanceof Expression) {
$this->targetNode = $node->expr;
// Don't traverse into the expression - we have what we need
// @phan-suppress-next-line PhanDeprecatedClassConstant - keep compat with php-parser 4.x baseline
return NodeTraverser::DONT_TRAVERSE_CHILDREN;
}
return null;
}
public function getDeepestNode(): ?Node
{
return $this->targetNode;
}
}

View File

@@ -0,0 +1,176 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion;
/**
* Fuzzy matching utility for tab completion.
*
* Matches candidates where the search string's characters appear in order,
* similar to Fish shell and modern IDE fuzzy completion.
*
* Examples:
* - "asum" matches "array_sum"
* - "stl" matches "strtolower"
* - "AE" matches "ArrayException" (case-insensitive)
*/
class FuzzyMatcher
{
/**
* Filter candidates using fuzzy matching.
*
* Returns candidates where all characters in the search string appear
* in order within the candidate string (case-insensitive).
*
* Results are sorted by match quality (exact prefix matches first,
* then by how early the match starts).
*
* @param string $search Search string (e.g., "asum")
* @param string[] $candidates Array of candidates to filter
*
* @return string[] Filtered and sorted candidates
*/
public static function filter(string $search, array $candidates): array
{
if ($search === '') {
$sorted = $candidates;
\sort($sorted);
return $sorted;
}
$matches = [];
foreach ($candidates as $candidate) {
$score = self::match($search, $candidate);
if ($score !== null) {
$matches[] = ['candidate' => $candidate, 'score' => $score];
}
}
// Sort by score (lower is better), then alphabetically
\usort($matches, function ($a, $b) {
if ($a['score'] !== $b['score']) {
return $a['score'] <=> $b['score'];
}
return \strcmp($a['candidate'], $b['candidate']);
});
return \array_column($matches, 'candidate');
}
/**
* Check if search matches candidate and return a quality score.
*
* Returns null if no match, or a numeric score where lower is better.
* Score factors:
* - Exact prefix match gets lowest score (best)
* - Earlier matches get better scores
* - Consecutive character matches get bonus
* - First character must match at start or after a word boundary
*
* @return int|null Match score (lower is better), or null if no match
*/
private static function match(string $search, string $candidate): ?int
{
$searchLen = \strlen($search);
$candidateLen = \strlen($candidate);
if ($searchLen === 0) {
return 0;
}
$searchLower = \strtolower($search);
$candidateLower = \strtolower($candidate);
// Check for exact prefix match first (best score)
if (\strpos($candidateLower, $searchLower) === 0) {
return 0;
}
// Check if it contains the search as a substring (very good score)
$substringPos = \strpos($candidateLower, $searchLower);
if ($substringPos !== false) {
// Only match if substring starts at a word boundary
if ($substringPos === 0 || self::isWordBoundary($candidate[$substringPos - 1])) {
return $substringPos + 1;
}
}
// Fuzzy match: characters must appear in order
// First character MUST match at start or after a word boundary
$searchIdx = 0;
$candidateIdx = 0;
$lastMatchIdx = -1;
$firstMatchIdx = null;
$consecutiveMatches = 0;
$firstCharFound = false;
while ($searchIdx < $searchLen && $candidateIdx < $candidateLen) {
if ($searchLower[$searchIdx] === $candidateLower[$candidateIdx]) {
// For the first character, ensure it's at a word boundary
if ($searchIdx === 0) {
if ($candidateIdx === 0 || self::isWordBoundary($candidate[$candidateIdx - 1])) {
$firstCharFound = true;
} else {
// First char not at word boundary, skip it
$candidateIdx++;
continue;
}
}
if ($firstMatchIdx === null) {
$firstMatchIdx = $candidateIdx;
}
// Track consecutive matches
if ($candidateIdx === $lastMatchIdx + 1) {
$consecutiveMatches++;
}
$lastMatchIdx = $candidateIdx;
$searchIdx++;
}
$candidateIdx++;
}
// Did we match all search characters, and did the first char match at a word boundary?
if ($searchIdx < $searchLen || !$firstCharFound) {
return null;
}
// Score: position of first match + distance between matches - consecutive bonus
// Lower score is better
$score = 100 + $firstMatchIdx + ($lastMatchIdx - $firstMatchIdx) - ($consecutiveMatches * 10);
return $score;
}
/**
* Check if a character is a word boundary.
*
* Word boundaries include: underscore, space, dash, slash, backslash, and other non-alphanumeric chars.
*/
private static function isWordBoundary(string $char): bool
{
return !\ctype_alnum($char);
}
/**
* Check if search string matches candidate (fuzzy).
*
* @return bool True if all characters in search appear in order in candidate
*/
public static function matches(string $search, string $candidate): bool
{
return self::match($search, $candidate) !== null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Refiner;
use Psy\Completion\AnalysisResult;
/**
* Narrows parser output into a more useful completion lane.
*
* Refiners handle context decisions that depend on more than the parser's
* immediate syntactic view of the input.
*/
interface AnalysisRefinerInterface
{
/**
* Refine the analysis result before sources are queried.
*/
public function refine(AnalysisResult $analysis): AnalysisResult;
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Refiner;
use Psy\Command\Command;
use Psy\CommandArgumentCompletionAware;
use Psy\CommandAware;
use Psy\CommandMapTrait;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* Hands command tails to command-owned completion once shell syntax is known.
*
* It resolves which command owns the current tail so argument completion can
* follow command-specific rules and vocabulary.
*/
class CommandContextRefiner implements AnalysisRefinerInterface, CommandAware
{
use CommandMapTrait;
/**
* @param Command[] $commands Array of PsySH commands
*/
public function __construct(array $commands)
{
$this->setCommands($commands);
}
/**
* {@inheritdoc}
*/
public function refine(AnalysisResult $analysis): AnalysisResult
{
if (!$this->supportsRefinement($analysis)) {
return $analysis;
}
if (!\preg_match('/^\s*([^\s]+)(\s+.*)$/s', $analysis->input, $matches)) {
return $analysis;
}
$commandName = $matches[1];
$command = $this->commandMap[$commandName] ?? null;
if (!$command instanceof CommandArgumentCompletionAware) {
return $analysis;
}
if (!$command->supportsArgumentCompletion($analysis)) {
return $analysis;
}
return $analysis->withContext(CompletionKind::COMMAND_ARGUMENT, $analysis->prefix, $commandName);
}
private function supportsRefinement(AnalysisResult $analysis): bool
{
if (($analysis->kinds & CompletionKind::COMMAND_OPTION) !== 0) {
return false;
}
return ($analysis->kinds & CompletionKind::COMMAND_ELIGIBLE) !== 0;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Refiner;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* Recognizes shell-shaped command input before generic code sources run.
*
* It classifies bare command heads and option tokens so the rest of the
* pipeline can treat shell commands as their own completion mode.
*/
class CommandSyntaxRefiner implements AnalysisRefinerInterface
{
/**
* {@inheritdoc}
*/
public function refine(AnalysisResult $analysis): AnalysisResult
{
if (($analysis->kinds & CompletionKind::COMMAND_ELIGIBLE) === 0) {
return $analysis;
}
$trimmed = \rtrim($analysis->input);
if (\preg_match('/^([a-z][a-z0-9-]*)\s+.*?(-{1,2}[\w-]*)$/', $trimmed, $matches)) {
return $analysis->withContext(CompletionKind::COMMAND_OPTION, $matches[2], $matches[1]);
}
if ($analysis->input !== $trimmed) {
return $analysis;
}
if (!\preg_match('/^([a-z][a-z0-9-]*)$/', $trimmed, $matches)) {
return $analysis;
}
return $analysis->withContext(
CompletionKind::COMMAND | CompletionKind::KEYWORD | CompletionKind::SYMBOL,
$matches[1]
);
}
}

View File

@@ -0,0 +1,355 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Refiner;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* Recovers useful completion lanes when valid PHP syntax is not yet available.
*
* This refiner keeps completion responsive while the user is still in the
* middle of typing an expression the parser cannot fully classify yet.
*/
class PartialInputRefiner implements AnalysisRefinerInterface
{
/**
* {@inheritdoc}
*/
public function refine(AnalysisResult $analysis): AnalysisResult
{
if (!$analysis->parseSucceeded) {
$partial = $this->analyzePartialInput($analysis->input, $analysis->tokens);
return $analysis->withContext($partial->kinds, $partial->prefix, $partial->leftSide, $partial->leftSideNode);
}
if ($analysis->kinds !== CompletionKind::UNKNOWN) {
return $analysis;
}
$partial = $this->analyzePartialInput($analysis->input, $analysis->tokens);
return $this->shouldPreferPartialAnalysis($partial)
? $analysis->withContext($partial->kinds, $partial->prefix, $partial->leftSide, $partial->leftSideNode)
: $analysis;
}
private function analyzePartialInput(string $input, array $tokens = []): AnalysisResult
{
$trimmed = \rtrim($input);
$hasTrailingSpace = $input !== $trimmed;
if (\preg_match('/\bnew\s+([\w\\\\]*)$/i', $input, $matches)) {
return new AnalysisResult(CompletionKind::CLASS_NAME, $matches[1]);
}
if (\preg_match('/^.*[;\{\}]\s*(\w+)$/', $trimmed, $matches)) {
if (!\preg_match('/(?:->|\?->)\w*$/', $trimmed) && !\preg_match('/::\w*$/', $trimmed)) {
if ($hasTrailingSpace) {
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
return new AnalysisResult(CompletionKind::KEYWORD | CompletionKind::SYMBOL, $matches[1]);
}
}
if (\preg_match('/(\$\w+)(?:->|\?->)([\w]*)$/', $trimmed, $matches)) {
return new AnalysisResult(CompletionKind::OBJECT_MEMBER, $matches[2], $matches[1]);
}
if (\preg_match('/([\w\\\\]*\\\\)$/', $trimmed, $matches)) {
return new AnalysisResult(CompletionKind::SYMBOL | CompletionKind::NAMESPACE, $matches[1]);
}
if (\preg_match('/([\w\\\\]+)::\$(\w*)$/', $trimmed, $matches)) {
return new AnalysisResult(CompletionKind::STATIC_MEMBER, $matches[2], $matches[1]);
}
if (\preg_match('/([\w\\\\]+)::([\w]*)$/', $trimmed, $matches)) {
return new AnalysisResult(CompletionKind::STATIC_MEMBER, $matches[2], $matches[1]);
}
$tokenizedObjectMember = $this->analyzeTokenizedObjectMemberAccess($input, $tokens);
if ($tokenizedObjectMember !== null) {
return $tokenizedObjectMember;
}
if (\preg_match('/\$(\w*)$/', $trimmed, $matches)) {
return new AnalysisResult(CompletionKind::VARIABLE, $matches[1]);
}
if (\preg_match('/([\w\\\\]+)$/', $trimmed, $matches)) {
if ($hasTrailingSpace) {
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
return new AnalysisResult(CompletionKind::SYMBOL, $matches[1]);
}
return new AnalysisResult(CompletionKind::UNKNOWN, '');
}
private function shouldPreferPartialAnalysis(AnalysisResult $partialAnalysis): bool
{
return \in_array($partialAnalysis->kinds, [
CompletionKind::VARIABLE,
CompletionKind::OBJECT_MEMBER,
CompletionKind::STATIC_MEMBER,
CompletionKind::CLASS_NAME,
CompletionKind::SYMBOL | CompletionKind::NAMESPACE,
], true);
}
private function analyzeTokenizedObjectMemberAccess(string $input, array $tokens): ?AnalysisResult
{
$entries = $this->flattenTokens($tokens);
if ($entries === []) {
return null;
}
$lastIndex = $this->findPreviousSignificantTokenIndex($entries, \count($entries) - 1);
if ($lastIndex === null) {
return null;
}
$prefix = '';
$operatorIndex = $lastIndex;
if ($this->isIdentifierToken($entries[$lastIndex])) {
$prefix = $entries[$lastIndex]['text'];
$operatorIndex = $this->findPreviousSignificantTokenIndex($entries, $lastIndex - 1);
if ($operatorIndex === null) {
return null;
}
}
if (!$this->isObjectAccessOperatorToken($entries[$operatorIndex]['token'])) {
return null;
}
$leftEndIndex = $this->findPreviousSignificantTokenIndex($entries, $operatorIndex - 1);
if ($leftEndIndex === null) {
return null;
}
$leftStartIndex = $this->findExpressionStartIndex($entries, $leftEndIndex);
if ($leftStartIndex === null) {
return null;
}
$leftSide = \mb_substr(
$input,
$entries[$leftStartIndex]['start'],
$entries[$leftEndIndex]['end'] - $entries[$leftStartIndex]['start']
);
$leftSide = $this->normalizeLeftExpression($leftSide);
if ($leftSide === '') {
return null;
}
return new AnalysisResult(CompletionKind::OBJECT_MEMBER, $prefix, $leftSide);
}
private function flattenTokens(array $tokens): array
{
$entries = [];
$position = 0;
foreach ($tokens as $token) {
$text = \is_array($token) ? $token[1] : $token;
if (\is_array($token) && $token[0] === \T_OPEN_TAG) {
continue;
}
$length = \mb_strlen($text);
$entries[] = [
'token' => $token,
'text' => $text,
'start' => $position,
'end' => $position + $length,
];
$position += $length;
}
return $entries;
}
private function findPreviousSignificantTokenIndex(array $entries, int $start): ?int
{
for ($i = $start; $i >= 0; $i--) {
$token = $entries[$i]['token'];
if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
continue;
}
return $i;
}
return null;
}
private function findExpressionStartIndex(array $entries, int $endIndex): ?int
{
$parenDepth = 0;
$bracketDepth = 0;
$braceDepth = 0;
for ($i = $endIndex; $i >= 0; $i--) {
$token = $entries[$i]['token'];
$text = $entries[$i]['text'];
if (!\is_array($token)) {
if ($text === ')') {
$parenDepth++;
continue;
}
if ($text === '(') {
if ($parenDepth > 0) {
$parenDepth--;
continue;
}
} elseif ($text === ']') {
$bracketDepth++;
continue;
} elseif ($text === '[') {
if ($bracketDepth > 0) {
$bracketDepth--;
continue;
}
} elseif ($text === '}') {
$braceDepth++;
continue;
} elseif ($text === '{') {
if ($braceDepth > 0) {
$braceDepth--;
continue;
}
}
if ($parenDepth === 0 && $bracketDepth === 0 && $braceDepth === 0 && $this->isExpressionBoundaryToken($token)) {
return $this->findNextNonWhitespaceTokenIndex($entries, $i + 1);
}
continue;
}
if ($parenDepth === 0 && $bracketDepth === 0 && $braceDepth === 0 && $this->isExpressionBoundaryToken($token)) {
return $this->findNextNonWhitespaceTokenIndex($entries, $i + 1);
}
}
return $this->findNextNonWhitespaceTokenIndex($entries, 0);
}
private function findNextNonWhitespaceTokenIndex(array $entries, int $start): ?int
{
for ($i = $start; $i < \count($entries); $i++) {
$token = $entries[$i]['token'];
if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
continue;
}
return $i;
}
return null;
}
private function isExpressionBoundaryToken($token): bool
{
if (!\is_array($token)) {
return \in_array($token, [';', '{', '}', ',', '=', '+', '-', '*', '/', '%', '.', '?', ':', '!', '&', '|', '^', '<', '>'], true);
}
return \in_array($token[0], [
\T_DOUBLE_ARROW,
\T_BOOLEAN_AND,
\T_BOOLEAN_OR,
\T_LOGICAL_AND,
\T_LOGICAL_OR,
\T_LOGICAL_XOR,
\T_COALESCE,
\T_RETURN,
\T_ECHO,
\T_PRINT,
\T_THROW,
], true);
}
private function normalizeLeftExpression(string $expression): string
{
$expression = \trim($expression);
while ($this->isWrappedInParentheses($expression)) {
$expression = \trim(\substr($expression, 1, -1));
}
return $expression;
}
private function isWrappedInParentheses(string $expression): bool
{
if (\strlen($expression) < 2 || $expression[0] !== '(' || \substr($expression, -1) !== ')') {
return false;
}
$depth = 0;
$length = \strlen($expression);
for ($i = 0; $i < $length; $i++) {
if ($expression[$i] === '(') {
$depth++;
} elseif ($expression[$i] === ')') {
$depth--;
}
if ($depth === 0 && $i < $length - 1) {
return false;
}
}
return $depth === 0;
}
private function isIdentifierToken(array $entry): bool
{
if (!\is_array($entry['token'])) {
return false;
}
return \in_array($entry['token'][0], [
\T_STRING,
\defined('T_NAME_QUALIFIED') ? \T_NAME_QUALIFIED : \T_STRING,
\defined('T_NAME_FULLY_QUALIFIED') ? \T_NAME_FULLY_QUALIFIED : \T_STRING,
\defined('T_NAME_RELATIVE') ? \T_NAME_RELATIVE : \T_STRING,
], true);
}
private function isObjectAccessOperatorToken($token): bool
{
if (!\is_array($token)) {
return false;
}
if ($token[0] === \T_OBJECT_OPERATOR) {
return true;
}
return \defined('T_NULLSAFE_OBJECT_OPERATOR') && $token[0] === \T_NULLSAFE_OBJECT_OPERATOR;
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Completion\AnalysisResult;
use Psy\Completion\SymbolCatalog;
/**
* Catalog-backed completion source.
*
* Provides completions for symbol types backed by SymbolCatalog (classes,
* interfaces, traits, functions, constants).
*/
class CatalogSource implements SourceInterface
{
private int $kind;
private SymbolCatalog $catalog;
/** @var callable */
private $catalogMethod;
/**
* @param int $kind CompletionKind bitmask to match
* @param callable $catalogMethod Callable that takes a SymbolCatalog and returns string[]
*/
public function __construct(int $kind, callable $catalogMethod, ?SymbolCatalog $catalog = null)
{
$this->kind = $kind;
$this->catalogMethod = $catalogMethod;
$this->catalog = $catalog ?? new SymbolCatalog();
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & $this->kind) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
$prefix = $analysis->prefix;
$all = ($this->catalogMethod)($this->catalog);
// If we have a prefix, try prefix matching first
if ($prefix !== '') {
$lowerPrefix = \strtolower($prefix);
$matches = [];
foreach ($all as $name) {
if (\strpos(\strtolower($name), $lowerPrefix) === 0) {
$matches[] = $name;
}
}
// If we found enough prefix matches, return those (sorted)
if (\count($matches) >= 10) {
\sort($matches);
return $matches;
}
}
return $all;
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* Class constant completion source.
*
* Provides completions for class constants using reflection.
* Handles Class::CONSTANT, self::CONSTANT, parent::CONSTANT, static::CONSTANT.
*/
class ClassConstantSource implements SourceInterface
{
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::CLASS_CONSTANT) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
if (empty($analysis->leftSideTypes)) {
return [];
}
$constants = [];
foreach ($analysis->leftSideTypes as $type) {
try {
$reflection = new \ReflectionClass($type);
} catch (\ReflectionException $e) {
continue;
}
foreach ($reflection->getReflectionConstants() as $constant) {
if ($constant->isPublic()) {
$constants[] = $constant->getName();
}
}
}
return \array_values(\array_unique($constants));
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Command\Command;
use Psy\CommandArgumentCompletionAware;
use Psy\CommandAware;
use Psy\CommandMapTrait;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* Command positional argument completion source.
*
* Delegates argument-tail completion to commands that explicitly opt in.
*/
class CommandArgumentSource implements CommandAware, SourceInterface
{
use CommandMapTrait;
/**
* @param Command[] $commands Array of PsySH commands
*/
public function __construct(array $commands)
{
$this->setCommands($commands);
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::COMMAND_ARGUMENT) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
$commandName = \is_string($analysis->leftSide) ? $analysis->leftSide : null;
$command = $commandName !== null ? ($this->commandMap[$commandName] ?? null) : null;
if (!$command instanceof CommandArgumentCompletionAware) {
return [];
}
// CommandContextRefiner already verified supportsArgumentCompletion()
// before setting COMMAND_ARGUMENT kind.
return $command->getArgumentCompletions($analysis);
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Command\Command;
use Psy\CommandAware;
use Psy\CommandMapTrait;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
use Symfony\Component\Console\Input\StringInput;
/**
* Command option/argument completion source.
*
* Provides completions for PsySH command options (e.g., --option, -o) and arguments.
*/
class CommandOptionSource implements CommandAware, SourceInterface
{
use CommandMapTrait;
/**
* @param Command[] $commands Array of PsySH commands
*/
public function __construct(array $commands)
{
$this->setCommands($commands);
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::COMMAND_OPTION) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
$commandName = $analysis->leftSide;
if (!\is_string($commandName) || !isset($this->commandMap[$commandName])) {
return [];
}
$command = $this->commandMap[$commandName];
$input = $this->createInput($analysis);
$matches = [];
foreach ($command->getDefinition()->getOptions() as $option) {
$longName = '--'.$option->getName();
$shortName = $option->getShortcut() !== null ? '-'.$option->getShortcut() : null;
if (!$option->isArray() && $this->isOptionUsed($input, $analysis, $longName, $shortName)) {
continue;
}
$matches[] = $longName;
if ($shortName !== null) {
$matches[] = $shortName;
}
}
\sort($matches);
return $matches;
}
/**
* Create a StringInput for token scanning, or null if input is empty/unparseable.
*/
private function createInput(AnalysisResult $analysis): ?StringInput
{
if ($analysis->input === '') {
return null;
}
try {
return new StringInput($analysis->input);
} catch (\Exception $e) {
return null;
}
}
/**
* Check whether an option has already been used in the input.
*/
private function isOptionUsed(?StringInput $input, AnalysisResult $analysis, string $longName, ?string $shortName): bool
{
if ($input === null) {
return false;
}
$names = [$longName];
if ($shortName !== null) {
$names[] = $shortName;
}
if ($input->hasParameterOption($names, true)) {
return true;
}
// hasParameterOption handles --long, --long=val, and -o as the
// first short option in a group, but not combined short options
// like -l in -al. Fall back to a simple string match for that.
if ($shortName !== null && \preg_match('/(^|\s)-\w*'.\preg_quote($shortName[1], '/').'/', $analysis->input)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Command\Command;
use Psy\CommandAware;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* PsySH command completion source.
*
* Provides completions for PsySH commands (e.g., ls, doc, show).
*/
class CommandSource implements CommandAware, SourceInterface
{
/** @var string[] */
private array $commandNames = [];
/**
* @param Command[] $commands Array of PsySH commands
*/
public function __construct(array $commands)
{
$this->setCommands($commands);
}
/**
* Set commands for completion.
*
* @param Command[] $commands
*/
public function setCommands(array $commands): void
{
$names = [];
foreach ($commands as $command) {
$names[] = $command->getName();
foreach ($command->getAliases() as $alias) {
$names[] = $alias;
}
}
\sort($names);
$this->commandNames = $names;
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::COMMAND) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
return $this->commandNames;
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
use Psy\Readline\Interactive\Input\History;
/**
* History-based completion source.
*
* Provides completion candidates from command history at the start of input.
*/
class HistorySource implements SourceInterface
{
private History $history;
public function __construct(History $history)
{
$this->history = $history;
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::COMMAND) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
if ($analysis->prefix === '') {
return [];
}
// Newest first, deduplicated
$commands = $this->history->search('', false);
return \array_values(\array_unique($commands));
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Completion\Source;
use Psy\Completion\AnalysisResult;
use Psy\Completion\CompletionKind;
/**
* PHP keyword completion source.
*
* Provides completions for function-like PHP keywords (echo, isset, etc.).
*/
class KeywordSource implements SourceInterface
{
/** @var string[] */
private array $keywords = [
'array',
'clone',
'declare',
'die',
'echo',
'empty',
'eval',
'exit',
'fn',
'include',
'include_once',
'isset',
'list',
'print',
'require',
'require_once',
'unset',
'yield',
];
public function __construct()
{
if (\PHP_VERSION_ID >= 80000) {
$this->keywords[] = 'match';
\sort($this->keywords);
}
}
/**
* {@inheritdoc}
*/
public function appliesToKind(int $kinds): bool
{
return ($kinds & CompletionKind::KEYWORD) !== 0;
}
/**
* {@inheritdoc}
*/
public function getCompletions(AnalysisResult $analysis): array
{
return $this->keywords;
}
}

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