First Commit
This commit is contained in:
+112
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user