First Commit

This commit is contained in:
Esteban
2026-07-03 16:27:34 +02:00
commit 7a7440daab
8955 changed files with 1117958 additions and 0 deletions
+112
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.');
}
}
+53
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;
}
}
+60
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
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);
}
}
@@ -0,0 +1,571 @@
<?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 ($pager === true) {
return 'builtin';
}
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_RECOVERABLE_ERROR',
'E_DEPRECATED',
'E_USER_DEPRECATED',
];
// E_STRICT was deprecated in PHP 8.4. The constant is still defined,
// but `\constant('E_STRICT')` triggers a deprecation notice.
if (\PHP_VERSION_ID < 80400) {
$names[] = 'E_STRICT';
}
$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;
}
}
@@ -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;
}
}
@@ -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;
}
}
+134
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);
}
}
+377
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;
}
}
+125
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;
}
}
+683
View File
@@ -0,0 +1,683 @@
<?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\CommandArgumentCompletionAware;
use Psy\Completion\AnalysisResult;
use Psy\Completion\SymbolCatalog;
use Psy\Configuration;
use Psy\Exception\UnexpectedTargetException;
use Psy\Formatter\DocblockFormatter;
use Psy\Formatter\ManualFormatter;
use Psy\Formatter\SignatureFormatter;
use Psy\Input\CodeArgument;
use Psy\ManualUpdater\ManualUpdate;
use Psy\Output\ShellOutputAdapter;
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 implements CommandArgumentCompletionAware
{
const INHERIT_DOC_TAG = '{@inheritdoc}';
private ?Configuration $config = null;
private SymbolCatalog $symbolCatalog;
private ?string $completionCandidateCacheKey = null;
/** @var string[] */
private array $completionCandidateCache = [];
public function __construct($name = null)
{
parent::__construct($name);
$this->symbolCatalog = new SymbolCatalog();
}
/**
* 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}
*/
public function supportsArgumentCompletion(AnalysisResult $analysis): bool
{
return !\preg_match('/(\?->|->|::)/', $this->completionTarget($analysis->input));
}
/**
* {@inheritdoc}
*/
public function getArgumentCompletions(AnalysisResult $analysis): array
{
$manual = $this->getShell()->getManual();
$cacheKey = $this->symbolCatalog->getVersion().':'.($manual ? \spl_object_id($manual).':'.$manual->getVersion() : 'none');
if ($this->completionCandidateCacheKey === $cacheKey) {
return $this->completionCandidateCache;
}
$candidates = \array_merge(
$this->getRuntimeTargetCandidates(),
$this->getLanguageConstructCandidates(),
$this->getManualIds()
);
$candidates = \array_values(\array_unique($candidates));
\sort($candidates);
$this->completionCandidateCacheKey = $cacheKey;
return $this->completionCandidateCache = $candidates;
}
/**
* {@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 ($this->looksLikeManualPageName($value)) {
if (($status = $this->tryWriteManualPageTarget($output, $shellOutput, $value, true)) !== null) {
return $status;
}
if ($suggestions = $this->getManualPageSuggestions($value, true)) {
$this->writeManualTargetSuggestions($output, $value, $suggestions);
return 1;
}
}
$docFromManual = false;
if (ReflectionLanguageConstruct::isLanguageConstruct($value)) {
$reflector = new ReflectionLanguageConstruct($value);
$doc = $this->getManualDocById($value, $output);
$docFromManual = $doc !== null;
} else {
try {
list($target, $reflector) = $this->getTargetAndReflector($value, $output);
} catch (UnexpectedTargetException $e) {
throw $e;
} catch (\RuntimeException|\InvalidArgumentException $e) {
if (($status = $this->tryWriteManualPageTarget($output, $shellOutput, $value)) !== null) {
return $status;
}
if ($suggestions = $this->getDocTargetSuggestions($value)) {
$this->writeManualTargetSuggestions($output, $value, $suggestions);
return 1;
}
throw $e;
}
$doc = $this->getManualDoc($reflector, $output);
$docFromManual = $doc !== null;
if (!$docFromManual) {
$doc = 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);
if ($docFromManual && (ReflectionLanguageConstruct::isLanguageConstruct($value) || $this->looksLikeManualPageName($value))) {
$this->writeManualPageSuggestions($output, $value);
}
}
// 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, $output) ?: 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, ?OutputInterface $output = null)
{
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 null;
}
return $this->getManualDocById($id, $output);
}
/**
* 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, ?OutputInterface $output = null)
{
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, $output ? $output->getFormatter() : null);
return $formatter->format($doc);
}
break;
}
}
return null;
}
private function writeManualPageSuggestions(OutputInterface $output, string $target): void
{
if (!$suggestions = $this->getManualPageSuggestions($target)) {
return;
}
$output->writeln('');
$output->writeln($this->formatManualPageSuggestions($suggestions));
}
private function writeManualPageDoc(ShellOutputAdapter $shellOutput, string $doc, string $target): void
{
$shellOutput->page(function (OutputInterface $pagedOutput) use ($doc, $target): void {
$pagedOutput->writeln($doc);
$this->writeManualPageSuggestions($pagedOutput, $target);
});
}
private function tryWriteManualPageTarget(OutputInterface $output, ShellOutputAdapter $shellOutput, string $target, bool $allowCaseInsensitiveLookup = false): ?int
{
if ($doc = $this->getManualDocById($target, $output)) {
$this->writeManualPageDoc($shellOutput, $doc, $target);
return 0;
}
if (!$allowCaseInsensitiveLookup) {
return null;
}
if (($manualPageId = $this->findManualPageId($target)) === null) {
return null;
}
if ($doc = $this->getManualDocById($manualPageId, $output)) {
$this->writeManualPageDoc($shellOutput, $doc, $manualPageId);
return 0;
}
$this->writeManualPageLoadError($output, $manualPageId);
return 1;
}
/**
* @param string[] $suggestions
*/
private function writeManualTargetSuggestions(OutputInterface $output, string $target, array $suggestions): void
{
$output->writeln($this->formatErrorLabel('Unknown target').' '.$target);
$output->writeln('');
$output->writeln('<comment>Did you mean?</comment>');
foreach ($suggestions as $suggestion) {
$output->writeln(' doc '.$suggestion);
}
}
private function writeManualPageLoadError(OutputInterface $output, string $manualPageId): void
{
$output->writeln($this->formatErrorLabel('Manual page exists but could not be loaded').' '.$manualPageId);
}
private function formatErrorLabel(string $label): string
{
$indent = $this->config && $this->config->theme()->compact() ? '' : ' ';
return \sprintf('%s<error> %s </error>', $indent, $label);
}
private function looksLikeManualPageName(string $target): bool
{
return \strpos($target, '.') !== false;
}
/**
* @return string[]
*/
private function getManualPageSuggestions(string $target, bool $broad = false): array
{
$target = \strtolower(\trim($target));
if ($target === '') {
return [];
}
$manualIds = $this->getManualIds();
$suffixes = ['.'.$target];
$suggestions = [];
foreach ($manualIds as $id) {
$normalizedId = \strtolower($id);
if ($normalizedId === $target) {
continue;
}
foreach ($suffixes as $suffix) {
if (\substr_compare($normalizedId, $suffix, -\strlen($suffix)) === 0) {
$suggestions[$id] = true;
break;
}
}
}
$suggestions = \array_keys($suggestions);
\sort($suggestions);
if (!empty($suggestions)) {
return \array_slice($suggestions, 0, 5);
}
if (!$broad) {
return [];
}
return $this->getFuzzySuggestions($target, $manualIds);
}
/**
* @return string[]
*/
private function getDocTargetSuggestions(string $target): array
{
if ($suggestions = $this->getManualPageSuggestions($target, true)) {
return $suggestions;
}
return $this->getFuzzyRuntimeTargetSuggestions($target);
}
/**
* @return string[]
*/
private function getFuzzyRuntimeTargetSuggestions(string $target): array
{
$target = \strtolower(\trim($target, " \t\n\r\0\x0B\\"));
if ($target === '') {
return [];
}
$candidates = \array_merge(
$this->getRuntimeTargetCandidates(),
$this->getLanguageConstructCandidates()
);
return $this->getFuzzySuggestions($target, $candidates, function ($candidate) {
return \strtolower(\trim($candidate, '\\'));
});
}
/**
* @param string[] $candidates
*
* @return string[]
*/
private function getFuzzySuggestions(string $target, array $candidates, ?callable $normalize = null): array
{
$normalize = $normalize ?? function ($candidate) {
return \strtolower($candidate);
};
$maxDistance = $this->suggestionDistance($target);
$suggestions = [];
foreach ($candidates as $candidate) {
$normalizedCandidate = $normalize($candidate);
if ($normalizedCandidate === $target || \abs(\strlen($normalizedCandidate) - \strlen($target)) > $maxDistance) {
continue;
}
$distance = \levenshtein($target, $normalizedCandidate);
if ($distance <= $maxDistance) {
$suggestions[] = [$distance, $candidate];
}
}
\usort($suggestions, function ($left, $right) {
return [$left[0], $left[1]] <=> [$right[0], $right[1]];
});
return \array_slice(\array_map(function ($suggestion) {
return $suggestion[1];
}, $suggestions), 0, 5);
}
/**
* @return string[]
*/
private function getRuntimeTargetCandidates(): array
{
$candidates = \array_merge(
$this->symbolCatalog->getFunctions(),
$this->symbolCatalog->getClasses(),
$this->symbolCatalog->getInterfaces(),
$this->symbolCatalog->getTraits(),
$this->symbolCatalog->getConstants()
);
$candidates = \array_values(\array_unique($candidates));
\sort($candidates);
return $candidates;
}
/**
* @return string[]
*/
private function getLanguageConstructCandidates(): array
{
return ReflectionLanguageConstruct::getNames();
}
private function findManualPageId(string $target): ?string
{
$target = \strtolower(\trim($target));
foreach ($this->getManualIds() as $id) {
if (\strtolower($id) === $target) {
return $id;
}
}
return null;
}
/**
* @return string[]
*/
private function getManualIds(): array
{
$manual = $this->getShell()->getManual();
if (!$manual) {
return [];
}
return $manual->getIds();
}
/**
* @param string[] $suggestions
*/
private function formatManualPageSuggestions(array $suggestions): string
{
return \sprintf('<comment>Related manual pages:</comment> %s', \implode(', ', $suggestions));
}
private function completionTarget(string $input): string
{
if (!\preg_match('/^\s*[^\s]+\s+(.*)$/s', $input, $matches)) {
return '';
}
return $matches[1];
}
private function suggestionDistance(string $target): int
{
return \max(2, (int) \floor(\strlen($target) / 6));
}
}
+84
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;
}
}
+216
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;
}
}
+54
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');
}
}
+188
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();
}
}
+298
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\ConfigPaths;
use Psy\Exception\RuntimeException;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutputAdapter;
use Psy\Readline\InteractiveReadlineInterface;
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.'),
new InputOption('session', '', InputOption::VALUE_NONE, 'Only show history from this REPL session.'),
$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>
<return>>>> history --session</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']);
if ($input->getOption('clear')) {
$this->validateClearIsUnrestricted($input);
$this->clearHistory();
$output->writeln('<info>History cleared.</info>');
return 0;
}
// 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, $input->getOption('session'));
$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') || $input->getOption('session'))) {
throw new \InvalidArgumentException('You must limit history via --head, --tail, --show or --session before replaying');
}
$count = \count($history);
$output->writeln(\sprintf('Replaying %d line%s of history', $count, ($count !== 1) ? 's' : ''));
$this->getShell()->addInput($history);
} 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, bool $session): array
{
if ($session) {
if (!($this->readline instanceof InteractiveReadlineInterface)) {
throw new RuntimeException('The --session option is only available with interactive readline.');
}
$history = $this->readline->listSessionHistory();
} else {
$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));
}
}
private function validateClearIsUnrestricted(InputInterface $input): void
{
foreach (['show', 'head', 'tail', 'session', 'grep', 'insensitive', 'invert'] as $opt) {
if ($input->getOption($opt)) {
throw new RuntimeException('The --clear option cannot be combined with history filters or range options.');
}
}
}
/**
* Clear the readline history.
*/
private function clearHistory()
{
$this->readline->clearHistory();
}
public static function escape(string $string): string
{
return OutputFormatter::escape($string);
}
}
+287
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);
}
}
}
@@ -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';
}
}
}
@@ -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;
}
}
@@ -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;
}
}
+114
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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -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;
}
}
+142
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);
}
}
+43
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;
}
}
+364
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)));
}
}
}
+298
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]];
}
}
}
+123
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;
}
}
+124
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;
}
}
+176
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[\intdiv($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)));
}
}
@@ -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)]);
}
}
+99
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);
}
}
+137
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;
}
}
+130
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;
}
}
+92
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);
}
}
}