update lock clucknut
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 14s
Build, Push and Deploy / build-and-push (push) Successful in 3m14s
Build, Push and Deploy / deploy-staging (push) Successful in 25s
Build, Push and Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-18 20:32:18 +07:00
parent 4554035227
commit dcaf267458
3359 changed files with 153185 additions and 205489 deletions

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2012-2025 Justin Hileman
Copyright (c) 2012-2026 Justin Hileman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

243
vendor/psy/psysh/bin/psysh vendored Normal file → Executable file
View File

@@ -4,7 +4,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -14,6 +14,9 @@
// We'll wrap this whole mess in a Closure so it doesn't leak any globals.
call_user_func(function () {
$cwd = null;
$cwdFromArg = false;
$forceTrust = false;
$forceUntrust = false;
// Find the cwd arg (if present)
$argv = isset($_SERVER['argv']) ? $_SERVER['argv'] : array();
@@ -24,22 +27,223 @@ call_user_func(function () {
exit(1);
}
$cwd = $argv[$i + 1];
break;
$cwdFromArg = true;
continue;
}
if (preg_match('/^--cwd=/', $arg)) {
$cwd = substr($arg, 6);
break;
$cwdFromArg = true;
continue;
}
if ($arg === '--trust-project') {
$forceTrust = true;
$forceUntrust = false;
} elseif ($arg === '--no-trust-project') {
$forceUntrust = true;
$forceTrust = false;
}
}
// Or fall back to the actual cwd
if (!isset($cwd)) {
if ($cwdFromArg) {
if (!@chdir($cwd)) {
fwrite(STDERR, 'Invalid --cwd directory: ' . $cwd . PHP_EOL);
exit(1);
}
}
// Fall back to the actual cwd, or normalize the path after chdir
if (!isset($cwd) || $cwdFromArg) {
$cwd = getcwd();
}
$cwd = str_replace('\\', '/', $cwd);
if ($cwdFromArg) {
$filtered = array();
$skipNext = false;
foreach ($argv as $arg) {
if ($skipNext) {
$skipNext = false;
continue;
}
if ($arg === '--cwd') {
$skipNext = true;
continue;
}
if (preg_match('/^--cwd=/', $arg)) {
continue;
}
$filtered[] = $arg;
}
$_SERVER['argv'] = $filtered;
$_SERVER['argc'] = count($filtered);
$argv = $filtered;
}
if (isset($_SERVER['PSYSH_TRUST_PROJECT']) && $_SERVER['PSYSH_TRUST_PROJECT'] !== '') {
$mode = strtolower(trim($_SERVER['PSYSH_TRUST_PROJECT']));
if (in_array($mode, array('true', '1'))) {
$forceTrust = true;
$forceUntrust = false;
} elseif (in_array($mode, array('false', '0'))) {
$forceUntrust = true;
$forceTrust = false;
} else {
fwrite(STDERR, 'Invalid PSYSH_TRUST_PROJECT value: ' . $_SERVER['PSYSH_TRUST_PROJECT'] . '. Expected: true, 1, false, or 0.' . PHP_EOL);
exit(1);
}
}
// Pass trust decision via env var and strip CLI flags. This allows a local
// psysh version to read the trust state while avoiding errors on older
// versions that don't understand --trust-project flags.
if ($forceTrust) {
$_SERVER['PSYSH_TRUST_PROJECT'] = 'true';
$_ENV['PSYSH_TRUST_PROJECT'] = 'true';
putenv('PSYSH_TRUST_PROJECT=true');
} elseif ($forceUntrust) {
$_SERVER['PSYSH_TRUST_PROJECT'] = 'false';
$_ENV['PSYSH_TRUST_PROJECT'] = 'false';
putenv('PSYSH_TRUST_PROJECT=false');
}
if ($forceTrust || $forceUntrust) {
$filtered = array();
foreach ($argv as $arg) {
if ($arg === '--trust-project' || $arg === '--no-trust-project') {
continue;
}
$filtered[] = $arg;
}
$_SERVER['argv'] = $filtered;
$_SERVER['argc'] = count($filtered);
$argv = $filtered;
}
$trustedRoots = array();
if (!$forceTrust) {
// Find the current config directory (matching ConfigPaths logic)
$currentConfigDir = null;
$fallbackConfigDir = null;
// Windows: %APPDATA%/PsySH takes priority
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
if (isset($_SERVER['APPDATA']) && $_SERVER['APPDATA']) {
$dir = str_replace('\\', '/', $_SERVER['APPDATA']).'/PsySH';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// XDG_CONFIG_HOME/psysh
if ($currentConfigDir === null && isset($_SERVER['XDG_CONFIG_HOME']) && $_SERVER['XDG_CONFIG_HOME']) {
$dir = rtrim(str_replace('\\', '/', $_SERVER['XDG_CONFIG_HOME']), '/').'/psysh';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
// HOME/.config/psysh (default XDG location)
if ($currentConfigDir === null && isset($_SERVER['HOME']) && $_SERVER['HOME']) {
$home = rtrim(str_replace('\\', '/', $_SERVER['HOME']), '/');
$dir = $home.'/.config/psysh';
$fallbackConfigDir = $fallbackConfigDir !== null ? $fallbackConfigDir : $dir;
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
// legacy
if ($currentConfigDir === null) {
$dir = $home.'/.psysh';
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// Windows: HOMEDRIVE/HOMEPATH fallback
if ($currentConfigDir === null && defined('PHP_WINDOWS_VERSION_MAJOR')) {
if (isset($_SERVER['HOMEDRIVE']) && isset($_SERVER['HOMEPATH']) && $_SERVER['HOMEDRIVE'] && $_SERVER['HOMEPATH']) {
$dir = rtrim(str_replace('\\', '/', $_SERVER['HOMEDRIVE'].'/'.$_SERVER['HOMEPATH']), '/').'/.psysh';
if (@is_dir($dir)) {
$currentConfigDir = $dir;
}
}
}
// Fall back to the first candidate directory if none exist yet
if ($currentConfigDir === null) {
$currentConfigDir = $fallbackConfigDir;
}
if ($currentConfigDir !== null) {
$trustFile = $currentConfigDir.'/trusted_projects.json';
if (is_file($trustFile)) {
$contents = file_get_contents($trustFile);
if ($contents !== false && $contents !== '') {
$data = json_decode($contents, true);
if (is_array($data)) {
foreach ($data as $dir) {
if (!is_string($dir) || $dir === '') {
continue;
}
$real = realpath($dir);
if ($real !== false) {
$dir = $real;
}
$trustedRoots[] = str_replace('\\', '/', $dir);
}
}
}
}
}
}
// Composer-generated bin proxies expose `_composer_autoload_path`, which points
// at the autoloader for the *current* project invoking `vendor/bin/psysh`.
// We use this to distinguish "already running via this project's local psysh"
// from "global psysh trying to hop into some other project's local psysh".
$proxyAutoloadPath = null;
if (isset($GLOBALS['_composer_autoload_path'])
&& is_string($GLOBALS['_composer_autoload_path'])
&& $GLOBALS['_composer_autoload_path'] !== ''
) {
$proxyAutoloadPath = realpath($GLOBALS['_composer_autoload_path']);
if ($proxyAutoloadPath === false) {
$proxyAutoloadPath = str_replace('\\', '/', $GLOBALS['_composer_autoload_path']);
} else {
$proxyAutoloadPath = str_replace('\\', '/', $proxyAutoloadPath);
}
}
$isCurrentProjectAutoload = function ($projectPath) use ($proxyAutoloadPath) {
if ($proxyAutoloadPath === null) {
return false;
}
$projectAutoloadPath = realpath($projectPath.'/vendor/autoload.php');
if ($projectAutoloadPath === false) {
return false;
}
return str_replace('\\', '/', $projectAutoloadPath) === $proxyAutoloadPath;
};
$markUntrustedProject = function ($projectPath, $prettyPath) {
fwrite(STDERR, 'Skipping local PsySH at ' . $prettyPath . ' (project is untrusted). Re-run with --trust-project to allow.' . PHP_EOL);
$_SERVER['PSYSH_UNTRUSTED_PROJECT'] = $projectPath;
$_ENV['PSYSH_UNTRUSTED_PROJECT'] = $projectPath;
putenv('PSYSH_UNTRUSTED_PROJECT='.$projectPath);
};
$chunks = explode('/', $cwd);
while (!empty($chunks)) {
$path = implode('/', $chunks);
@@ -54,7 +258,18 @@ call_user_func(function () {
if (isset($cfg['name']) && $cfg['name'] === 'psy/psysh') {
// We're inside the psysh project. Let's use the local Composer autoload.
if (is_file($path . '/vendor/autoload.php')) {
if (realpath($path) !== realpath(__DIR__ . '/..')) {
$realPath = realpath($path);
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
$pathReal = realpath($path);
$binReal = realpath(__DIR__ . '/..');
$isCurrentPsysh = ($pathReal !== false && $pathReal === $binReal) || $isCurrentProjectAutoload($path);
if (!$isCurrentPsysh && !$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
$markUntrustedProject($realPath, $prettyPath);
return;
}
if (!$isCurrentPsysh) {
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
}
@@ -69,11 +284,23 @@ call_user_func(function () {
// Or a composer.lock
if (is_file($path . '/composer.lock')) {
if ($cfg = json_decode(file_get_contents($path . '/composer.lock'), true)) {
foreach (array_merge($cfg['packages'], $cfg['packages-dev']) as $pkg) {
$packages = array_merge(isset($cfg['packages']) ? $cfg['packages'] : array(), isset($cfg['packages-dev']) ? $cfg['packages-dev'] : array());
foreach ($packages as $pkg) {
if (isset($pkg['name']) && $pkg['name'] === 'psy/psysh') {
// We're inside a project which requires psysh. We'll use the local Composer autoload.
if (is_file($path . '/vendor/autoload.php')) {
if (realpath($path . '/vendor') !== realpath(__DIR__ . '/../../..')) {
$realPath = realpath($path);
$realPath = $realPath ? str_replace('\\', '/', $realPath) : $path;
$vendorReal = realpath($path . '/vendor');
$binVendorReal = realpath(__DIR__ . '/../../..');
$isCurrentPsysh = ($vendorReal !== false && $vendorReal === $binVendorReal) || $isCurrentProjectAutoload($path);
if (!$isCurrentPsysh && !$forceTrust && ($forceUntrust || !in_array($realPath, $trustedRoots, true))) {
$markUntrustedProject($realPath, $prettyPath);
return;
}
if (!$isCurrentPsysh) {
fwrite(STDERR, 'Using local PsySH version at ' . $prettyPath . PHP_EOL);
}

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,11 +11,14 @@
namespace Psy;
use PhpParser\Error as PhpParserError;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser;
@@ -67,6 +70,7 @@ class CodeCleaner
private ?array $namespace = null;
private array $messages = [];
private array $aliasesByNamespace = [];
private array $aliasesByTypeByNamespace = [];
/**
* CodeCleaner constructor.
@@ -323,6 +327,7 @@ public function setAliasesForNamespace(?Name $namespace, array $aliases)
{
$namespaceKey = \strtolower($namespace ? $namespace->toString() : '');
$this->aliasesByNamespace[$namespaceKey] = $aliases;
$this->aliasesByTypeByNamespace[$namespaceKey][Use_::TYPE_NORMAL] = $aliases;
}
/**
@@ -340,7 +345,43 @@ public function getAliasesForNamespace($namespace): array
$namespaceName = $namespace instanceof Name ? $namespace->toString() : $namespace;
$namespaceKey = \strtolower($namespaceName ?? '');
return $this->aliasesByNamespace[$namespaceKey] ?? [];
return $this->aliasesByTypeByNamespace[$namespaceKey][Use_::TYPE_NORMAL] ?? $this->aliasesByNamespace[$namespaceKey] ?? [];
}
/**
* Set use statement aliases by import type for a specific namespace.
*
* @param Name|null $namespace Namespace name or Name node (null for global namespace)
* @param array $aliasesByType Map of Use_::TYPE_* constants to alias maps
*/
public function setAliasesByTypeForNamespace(?Name $namespace, array $aliasesByType): void
{
$namespaceKey = \strtolower($namespace ? $namespace->toString() : '');
$this->aliasesByTypeByNamespace[$namespaceKey] = $aliasesByType;
$this->aliasesByNamespace[$namespaceKey] = $aliasesByType[Use_::TYPE_NORMAL] ?? [];
}
/**
* Get use statement aliases by import type for a specific namespace.
*
* @param Name|string|null $namespace Namespace name or Name node (null for global namespace)
*
* @return array Map of Use_::TYPE_* constants to alias maps
*/
public function getAliasesByTypeForNamespace($namespace): array
{
$namespaceName = $namespace instanceof Name ? $namespace->toString() : $namespace;
$namespaceKey = \strtolower($namespaceName ?? '');
if (isset($this->aliasesByTypeByNamespace[$namespaceKey])) {
return $this->aliasesByTypeByNamespace[$namespaceKey];
}
if (!isset($this->aliasesByNamespace[$namespaceKey])) {
return [];
}
return [Use_::TYPE_NORMAL => $this->aliasesByNamespace[$namespaceKey]];
}
/**
@@ -437,6 +478,134 @@ public function resolveClassName(string $name): string
return $name;
}
/**
* Resolve a function name using current use statements and namespace.
*
* Returns the fully-qualified callable name, or null if the name would not
* resolve to a callable function in the current REPL scope.
*/
public function resolveFunctionName(string $name): ?string
{
if ($name === '') {
return null;
}
if ($name[0] === '\\') {
$name = \substr($name, 1);
return \function_exists($name) ? '\\'.$name : null;
}
$parts = \explode('\\', $name);
$namespace = $this->getNamespace();
$namespaceString = $namespace ? \implode('\\', $namespace) : null;
$functionAliases = $this->getAliasesByTypeForNamespace($namespaceString)[Use_::TYPE_FUNCTION] ?? [];
$firstPart = \strtolower($parts[0]);
if (isset($functionAliases[$firstPart])) {
$aliasName = $functionAliases[$firstPart];
// PHP-Parser 5.x uses getParts(), 4.x uses ->parts
$aliasParts = \method_exists($aliasName, 'getParts') ? $aliasName->getParts() : $aliasName->parts;
$resolved = \implode('\\', \array_merge($aliasParts, \array_slice($parts, 1)));
return \function_exists($resolved) ? '\\'.$resolved : null;
}
if ($namespace) {
$namespaced = \implode('\\', \array_merge($namespace, $parts));
if (\function_exists($namespaced)) {
return '\\'.$namespaced;
}
}
if (\count($parts) > 1) {
return null;
}
return \function_exists($name) ? '\\'.$name : null;
}
/**
* Resolve a direct function call from raw input in the current REPL scope.
*
* If $expectedName is provided, the input must be a direct call to that
* function name in order to be considered a collision.
*/
public function getCallableFunctionForInput(string $input, ?string $expectedName = null): ?string
{
try {
$stmts = $this->parse('<?php '.$input, false);
} catch (ParseErrorException $e) {
return null;
}
if ($stmts === false) {
return null;
}
$call = $this->getDirectFunctionCallFromStatements($stmts, $expectedName);
if ($call === null) {
return null;
}
$function = $this->resolveFunctionName($call->name->toString());
if ($function !== null && $this->functionCallMatchesArity($function, $call)) {
return $function;
}
return null;
}
/**
* Return the first direct function call expression from a statement list.
*
* @param \PhpParser\Node[] $stmts
*/
private function getDirectFunctionCallFromStatements(array $stmts, ?string $expectedName = null): ?FuncCall
{
$stmt = $stmts[0] ?? null;
if (!$stmt instanceof Expression) {
return null;
}
$expr = $stmt->expr;
if (!$expr instanceof FuncCall || !$expr->name instanceof Name) {
return null;
}
if ($expectedName !== null && \strtolower($expr->name->toString()) !== \strtolower($expectedName)) {
return null;
}
return $expr;
}
/**
* Check whether a parsed function call could satisfy the target function's arity.
*/
private function functionCallMatchesArity(string $function, FuncCall $call): bool
{
try {
$reflection = new \ReflectionFunction(\ltrim($function, '\\'));
} catch (\ReflectionException $e) {
return false;
}
// Unpacked args can satisfy any arity, so only validate fixed arg counts.
foreach ($call->args as $arg) {
if ($arg->unpack) {
return true;
}
}
$argCount = \count($call->args);
$required = $reflection->getNumberOfRequiredParameters();
$max = $reflection->isVariadic() ? \PHP_INT_MAX : $reflection->getNumberOfParameters();
return $argCount >= $required && $argCount <= $max;
}
/**
* Log a message from a CodeCleaner pass.
*
@@ -471,7 +640,7 @@ protected function parse(string $code, bool $requireSemicolons = false)
{
try {
return $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
} catch (PhpParserError $e) {
if ($this->parseErrorIsUnclosedString($e, $code)) {
return false;
}
@@ -495,13 +664,13 @@ protected function parse(string $code, bool $requireSemicolons = false)
try {
// Unexpected EOF, try again with an implicit semicolon
return $this->parser->parse($code.';');
} catch (\PhpParser\Error $e) {
} catch (PhpParserError $e) {
return false;
}
}
}
private function parseErrorIsEOF(\PhpParser\Error $e): bool
private function parseErrorIsEOF(PhpParserError $e): bool
{
$msg = $e->getRawMessage();
@@ -515,7 +684,7 @@ private function parseErrorIsEOF(\PhpParser\Error $e): bool
* their own special beautiful snowflake syntax error just for
* themselves.
*/
private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code): bool
private function parseErrorIsUnclosedString(PhpParserError $e, string $code): bool
{
if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
return false;
@@ -530,12 +699,12 @@ private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code):
return true;
}
private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, string $code): bool
private function parseErrorIsUnterminatedComment(PhpParserError $e, string $code): bool
{
return $e->getRawMessage() === 'Unterminated comment';
}
private function parseErrorIsTrailingComma(\PhpParser\Error $e, string $code): bool
private function parseErrorIsTrailingComma(PhpParserError $e, string $code): bool
{
return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (\substr(\rtrim($code), -1) === ',');
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -14,6 +14,7 @@
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Expr\Throw_;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Break_;
use PhpParser\Node\Stmt\Expression;
@@ -74,14 +75,14 @@ private function addImplicitReturn(array $nodes): array
$case->stmts[] = $caseLast;
}
}
} elseif ($last instanceof Expr && !($last instanceof Exit_)) {
} elseif ($last instanceof Expr && !($last instanceof Exit_) && !self::isThrowNode($last)) {
// @codeCoverageIgnoreStart
$nodes[\count($nodes) - 1] = new Return_($last, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
// @codeCoverageIgnoreEnd
} elseif ($last instanceof Expression && !($last->expr instanceof Exit_)) {
} elseif ($last instanceof Expression && !($last->expr instanceof Exit_) && !self::isThrowNode($last->expr)) {
$nodes[\count($nodes) - 1] = new Return_($last->expr, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
@@ -100,7 +101,7 @@ private function addImplicitReturn(array $nodes): array
// We're not adding a fallback return after namespace statements,
// because code outside namespace statements doesn't really work, and
// there's already an implicit return in the namespace statement anyway.
if (self::isNonExpressionStmt($last)) {
if (self::isNonExpressionStmt($last) && !self::isThrowNode($last)) {
$nodes[] = new Return_(NoReturnValue::create());
}
@@ -122,4 +123,13 @@ private static function isNonExpressionStmt(Node $node): bool
!$node instanceof Return_ &&
!$node instanceof Namespace_;
}
/**
* PHP-Parser 4.x modeled standalone `throw` as Stmt\Throw_, while newer
* versions expose it as Expr\Throw_ inside Stmt\Expression.
*/
private static function isThrowNode(Node $node): bool
{
return $node instanceof Throw_ || $node->getType() === 'Stmt_Throw';
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -267,11 +267,11 @@ private function buildShortNameMap(): void
{
$this->shortNameMap = [];
$allClasses = \array_merge(
\get_declared_classes(),
\get_declared_interfaces(),
\get_declared_traits()
);
$allClasses = [
...\get_declared_classes(),
...\get_declared_interfaces(),
...\get_declared_traits(),
];
// First pass: collect all matching classes
$candidatesByShortName = [];
@@ -342,9 +342,7 @@ private function shouldIncludeClass(string $fqn): bool
*/
private function normalizeNamespaces(array $namespaces): array
{
return \array_map(function ($namespace) {
return \trim($namespace, '\\').'\\';
}, $namespaces);
return \array_map(fn ($namespace) => \trim($namespace, '\\').'\\', $namespaces);
}
/**

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -29,6 +29,7 @@ abstract class NamespaceAwarePass extends CodeCleanerPass
protected array $namespace = [];
protected array $currentScope = [];
protected array $aliases = [];
protected array $aliasesByType = [];
protected ?CodeCleaner $cleaner = null;
/**
@@ -51,6 +52,7 @@ public function beforeTraverse(array $nodes)
{
$this->namespace = [];
$this->currentScope = [];
$this->aliasesByType = [];
return null;
}
@@ -71,23 +73,25 @@ public function enterNode(Node $node)
// Only restore use statement aliases for PsySH re-injected namespaces.
// Explicit namespace declarations start with a clean slate.
if ($this->cleaner && $node->getAttribute('psyshReinjected')) {
$this->aliases = $this->cleaner->getAliasesForNamespace($node->name);
$this->aliasesByType = $this->cleaner->getAliasesByTypeForNamespace($node->name);
$this->aliases = $this->aliasesByType[Use_::TYPE_NORMAL] ?? [];
} else {
$this->aliases = [];
$this->aliasesByType = [];
}
}
// Track use statements for alias resolution
if ($node instanceof Use_) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = $useItem->name;
$this->setAliasForType(\strtolower($useItem->getAlias()), $useItem->name, $this->getUseImportType($node, $useItem));
}
}
// Track group use statements
if ($node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = Name::concat($node->prefix, $useItem->name);
$this->setAliasForType(\strtolower($useItem->getAlias()), Name::concat($node->prefix, $useItem->name), $this->getUseImportType($node, $useItem));
}
}
@@ -108,14 +112,17 @@ public function enterNode(Node $node)
public function leaveNode(Node $node)
{
if ($node instanceof Namespace_) {
$this->syncCompatAliases();
// Open namespaces (like `namespace Foo;`) have kind == KIND_SEMICOLON.
if ($node->getAttribute('kind') === Namespace_::KIND_SEMICOLON || $node->getAttribute('psyshReinjected')) {
if ($this->cleaner) {
$this->cleaner->setAliasesForNamespace($node->name, $this->aliases);
$this->cleaner->setAliasesByTypeForNamespace($node->name, $this->aliasesByType);
}
}
$this->aliases = [];
$this->aliasesByType = [];
}
return null;
@@ -130,6 +137,8 @@ public function leaveNode(Node $node)
*/
protected function getFullyQualifiedName($name): string
{
$this->syncCompatAliases();
if ($name instanceof FullyQualifiedName) {
return \implode('\\', $this->getParts($name));
}
@@ -166,4 +175,54 @@ protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
protected function getAliasesForType(int $type): array
{
$this->syncCompatAliases();
return $this->aliasesByType[$type] ?? [];
}
private function setAliasForType(string $alias, Name $name, int $type): void
{
$this->aliasesByType[$type][$alias] = $name;
if ($type === Use_::TYPE_NORMAL) {
$this->aliases[$alias] = $name;
}
}
/**
* Sync $aliases into $aliasesByType[TYPE_NORMAL] for subclasses that read $aliases directly.
*/
private function syncCompatAliases(): void
{
if ($this->aliases === []) {
unset($this->aliasesByType[Use_::TYPE_NORMAL]);
return;
}
$this->aliasesByType[Use_::TYPE_NORMAL] = $this->aliases;
}
/**
* Resolve the import type for a use item across PHP-Parser 4.x and 5.x.
*
* Individual use items may specify their own type (e.g. in group use
* statements), otherwise fall back to the parent statement type.
*/
protected function getUseImportType(Node $node, Node $useItem): int
{
$itemType = $useItem->type ?? null;
if (\is_int($itemType) && $itemType !== Use_::TYPE_UNKNOWN) {
return $itemType;
}
$nodeType = $node->type ?? null;
if (\is_int($nodeType) && $nodeType !== Use_::TYPE_UNKNOWN) {
return $nodeType;
}
return Use_::TYPE_NORMAL;
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -106,7 +106,7 @@ private function setNamespace(?Name $namespace)
$this->cleaner->setNamespace($namespace);
// Always clear aliases when changing namespace
$this->cleaner->setAliasesForNamespace($namespace, []);
$this->cleaner->setAliasesByTypeForNamespace($namespace, []);
}
/**

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,7 +13,6 @@
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name; // @phan-suppress-current-line PhanUnreferencedUseNormal - used for type checks
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\UseItem;
@@ -70,9 +69,9 @@ public function beforeTraverse(array $nodes)
// Only re-inject use statements if this is a wrapper created by NamespacePass.
// This matches PHP behavior: explicit namespace declaration clears use statements.
if ($node->getAttribute('psyshReinjected')) {
$aliases = $this->cleaner->getAliasesForNamespace($node->name);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$aliasesByType = $this->cleaner->getAliasesByTypeForNamespace($node->name);
if (!empty($aliasesByType)) {
$useStatements = $this->createUseStatements($aliasesByType);
$node->stmts = \array_merge($useStatements, $node->stmts ?? []);
}
}
@@ -84,9 +83,9 @@ public function beforeTraverse(array $nodes)
// No namespace declaration in input, or re-applied by NamespacePass; re-inject use
// statements for the empty namespace.
$aliases = $this->cleaner->getAliasesForNamespace(null);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$aliasesByType = $this->cleaner->getAliasesByTypeForNamespace(null);
if (!empty($aliasesByType)) {
$useStatements = $this->createUseStatements($aliasesByType);
$nodes = \array_merge($useStatements, $nodes);
}
@@ -106,8 +105,8 @@ public function afterTraverse(array $nodes)
}
// Persist aliases if they're at the global level (not inside any namespace)
if (!empty($this->aliases)) {
$this->cleaner->setAliasesForNamespace(null, $this->aliases);
if (!empty($this->aliasesByType)) {
$this->cleaner->setAliasesByTypeForNamespace(null, $this->aliasesByType);
}
return null;
@@ -122,32 +121,40 @@ public function afterTraverse(array $nodes)
*/
private function validateUseStatement(Use_ $stmt): void
{
$seenAliases = [];
foreach ($stmt->uses as $useItem) {
$alias = \strtolower($useItem->getAlias());
$type = $this->getUseImportType($stmt, $useItem);
if (isset($this->aliases[$alias])) {
if (isset($seenAliases[$type][$alias]) || isset($this->getAliasesForType($type)[$alias])) {
throw new FatalErrorException(\sprintf('Cannot use %s as %s because the name is already in use', $useItem->name->toString(), $useItem->getAlias()), 0, \E_ERROR, null, $stmt->getStartLine());
}
$seenAliases[$type][$alias] = true;
}
}
/**
* Create use statement nodes from stored aliases.
*
* @param array $aliases Map of lowercase alias names to Name nodes
* @param array $aliasesByType Map of Use_::TYPE_* constants to alias maps
*
* @return Use_[] Array of use statement nodes
*/
private function createUseStatements(array $aliases): array
private function createUseStatements(array $aliasesByType): array
{
$useStatements = [];
foreach ($aliases as $alias => $name) {
// Create UseItem (PHP-Parser 5.x) or UseUse (PHP-Parser 4.x)
$useItem = \class_exists(UseItem::class)
? new UseItem($name, new Identifier($alias))
: new UseUse($name, $alias);
// Mark as re-injected so we don't validate it
$useStatements[] = new Use_([$useItem], Use_::TYPE_NORMAL, ['psyshReinjected' => true]);
foreach ([Use_::TYPE_NORMAL, Use_::TYPE_FUNCTION, Use_::TYPE_CONSTANT] as $type) {
foreach ($aliasesByType[$type] ?? [] as $alias => $name) {
// Create UseItem (PHP-Parser 5.x) or UseUse (PHP-Parser 4.x)
$useItem = \class_exists(UseItem::class)
? new UseItem($name, new Identifier($alias))
: new UseUse($name, $alias);
// Mark as re-injected so we don't validate it
$useStatements[] = new Use_([$useItem], $type, ['psyshReinjected' => true]);
}
}
return $useStatements;

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,7 +11,10 @@
namespace Psy\Command;
use Psy\Output\ShellOutput;
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;
@@ -21,8 +24,10 @@
*
* Shows and clears the buffer for the current multi-line expression.
*/
class BufferCommand extends Command
class BufferCommand extends Command implements ReadlineAware
{
private ?Readline $readline = null;
/**
* {@inheritdoc}
*/
@@ -52,18 +57,32 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shell = $this->getShell();
$buf = $shell->getCodeBuffer();
$shellOutput = $this->shellOutput($output);
$readline = $this->getLegacyReadline();
$legacyBuffer = $readline->getBuffer();
$shellBuffer = $shell->getPendingCodeBuffer();
$buf = $legacyBuffer !== [] ? $legacyBuffer : $shellBuffer;
if ($input->getOption('clear')) {
$shell->resetCodeBuffer();
$output->writeln($this->formatLines($buf, 'urgent'), ShellOutput::NUMBER_LINES);
$readline->clearBuffer();
if ($shellBuffer !== []) {
$shell->clearPendingCodeBuffer();
}
$shellOutput->writeln($this->formatLines($buf, 'urgent'), ShellOutputAdapter::NUMBER_LINES);
} else {
$output->writeln($this->formatLines($buf), ShellOutput::NUMBER_LINES);
$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.
*
@@ -76,8 +95,18 @@ protected function formatLines(array $lines, string $type = 'return'): array
{
$template = \sprintf('<%s>%%s</%s>', $type, $type);
return \array_map(function ($line) use ($template) {
return \sprintf($template, $line);
}, $lines);
return \array_map(fn ($line) => \sprintf($template, $line), $lines);
}
/**
* Get the active multiline buffer from the legacy shim.
*/
private function getLegacyReadline(): LegacyReadline
{
if ($this->readline instanceof LegacyReadline) {
return $this->readline;
}
throw new \LogicException('BufferCommand requires LegacyReadline.');
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,6 +11,7 @@
namespace Psy\Command;
use PhpParser\Error as PhpParserError;
use PhpParser\Parser;
use Psy\Exception\ParseErrorException;
use Psy\ParserFactory;
@@ -42,7 +43,7 @@ public function parse(string $code): array
try {
return $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
} catch (PhpParserError $e) {
if (\strpos($e->getMessage(), 'unexpected EOF') === false) {
throw ParseErrorException::fromParseError($e);
}
@@ -50,7 +51,7 @@ public function parse(string $code): array
// If we got an unexpected EOF, let's try it again with a semicolon.
try {
return $this->parser->parse($code.';');
} catch (\PhpParser\Error $_e) {
} catch (PhpParserError $_e) {
// Throw the original error, not the semicolon one.
throw ParseErrorException::fromParseError($e);
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,11 +11,17 @@
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;
/**
@@ -52,6 +58,23 @@ protected function getShell(): 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}
*/
@@ -83,6 +106,14 @@ public function asText(): string
return \implode("\n", $messages);
}
/**
* Render help text for the current input context.
*/
public function asTextForInput(InputInterface $input): string
{
return $this->asText();
}
/**
* {@inheritdoc}
*/
@@ -90,9 +121,10 @@ private function getArguments(): array
{
$hidden = $this->getHiddenArguments();
return \array_filter($this->getNativeDefinition()->getArguments(), function ($argument) use ($hidden) {
return !\in_array($argument->getName(), $hidden);
});
return \array_filter(
$this->getNativeDefinition()->getArguments(),
fn ($argument) => !\in_array($argument->getName(), $hidden)
);
}
/**
@@ -112,9 +144,10 @@ private function getOptions(): array
{
$hidden = $this->getHiddenOptions();
return \array_filter($this->getNativeDefinition()->getOptions(), function ($option) use ($hidden) {
return !\in_array($option->getName(), $hidden);
});
return \array_filter(
$this->getNativeDefinition()->getOptions(),
fn ($option) => !\in_array($option->getName(), $hidden)
);
}
/**
@@ -271,4 +304,12 @@ protected function getTable(OutputInterface $output)
->setRows([])
->setStyle($style);
}
/**
* Get a ShellOutputAdapter for the given output.
*/
protected function shellOutput(OutputInterface $output): ShellOutputAdapter
{
return new ShellOutputAdapter($output);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -17,9 +17,9 @@
use Psy\Formatter\SignatureFormatter;
use Psy\Input\CodeArgument;
use Psy\ManualUpdater\ManualUpdate;
use Psy\Output\ShellOutput;
use Psy\Reflection\ReflectionConstant;
use Psy\Reflection\ReflectionLanguageConstruct;
use Psy\Util\Tty;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
@@ -85,6 +85,8 @@ protected function configure(): void
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shellOutput = $this->shellOutput($output);
if ($input->getOption('update-manual') !== false) {
return $this->handleUpdateManual($input, $output);
}
@@ -104,9 +106,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$hasManual = $this->getShell()->getManual() !== null;
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$shellOutput->startPaging();
// Maybe include the declaring class
if ($reflector instanceof \ReflectionMethod || $reflector instanceof \ReflectionProperty) {
@@ -120,7 +120,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln('<warning>PHP manual not found</warning>');
$output->writeln(' To document core PHP functionality, download the PHP reference manual:');
$output->writeln(' https://github.com/bobthecow/psysh/wiki/PHP-manual');
} else {
} elseif ($doc !== null) {
$output->writeln($doc);
}
@@ -146,9 +146,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
$shellOutput->stopPaging();
// Set some magic local variables
$this->setCommandScopeVariables($reflector);
@@ -324,7 +322,7 @@ private function getManualDocById($id)
case 3:
if ($doc = $manual->get($id)) {
$width = $this->getTerminalWidth();
$width = Tty::getWidth();
$formatter = new ManualFormatter($width, $manual);
return $formatter->format($doc);
@@ -335,35 +333,4 @@ private function getManualDocById($id)
return null;
}
/**
* Get the current terminal width for text wrapping.
*
* @return int Terminal width in columns
*/
private function getTerminalWidth(): int
{
// Query terminal size directly
if (\function_exists('shell_exec')) {
// Output format: "rows cols"
$output = @\shell_exec('stty size </dev/tty 2>/dev/null');
if ($output && \preg_match('/^\d+ (\d+)$/', \trim($output), $matches)) {
return (int) $matches[1];
}
$width = @\shell_exec('tput cols </dev/tty 2>/dev/null');
if ($width && \is_numeric(\trim($width))) {
return (int) \trim($width);
}
}
// Check COLUMNS environment variable (may be stale after resize)
$width = \getenv('COLUMNS');
if ($width && \is_numeric(\trim($width))) {
return (int) \trim($width);
}
// Fallback to 100 if we can't detect
return 100;
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,9 +11,7 @@
namespace Psy\Command;
use Psy\Exception\RuntimeException;
use Psy\Input\CodeArgument;
use Psy\Output\ShellOutput;
use Psy\VarDumper\Presenter;
use Psy\VarDumper\PresenterAware;
use Symfony\Component\Console\Input\InputInterface;
@@ -73,13 +71,9 @@ protected function configure(): void
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$output instanceof ShellOutput) {
throw new RuntimeException('DumpCommand requires a ShellOutput');
}
$depth = $input->getOption('depth');
$target = $this->resolveCode($input->getArgument('target'));
$output->page($this->presenter->present($target, $depth, $input->getOption('all') ? Presenter::VERBOSE : 0));
$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));

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -14,6 +14,7 @@
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;
@@ -154,9 +155,41 @@ 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);
\proc_close($proc);
// 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);

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,7 +11,9 @@
namespace Psy\Command;
use Psy\Output\ShellOutput;
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;
@@ -24,7 +26,12 @@
*/
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}
@@ -51,6 +58,14 @@ 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}
*
@@ -58,10 +73,13 @@ public function setCommand(Command $command)
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shellOutput = $this->shellOutput($output);
if ($this->command !== null) {
// help for an individual command
$output->page($this->command->asText());
$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 {
@@ -78,42 +96,93 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 1;
}
$output->page($cmd->asText());
if (!$cmd instanceof Command) {
throw new \RuntimeException(\sprintf('Expected Psy\Command\Command instance, got %s', \get_class($cmd)));
}
$shellOutput->page($cmd->asTextForInput($input));
} else {
// list available commands
$commands = $this->getApplication()->all();
$table = $this->getTable($output);
foreach ($commands as $name => $command) {
if ($name !== $command->getName()) {
continue;
}
if ($command->getAliases()) {
$aliases = \sprintf('<comment>Aliases:</comment> %s', \implode(', ', $command->getAliases()));
} else {
$aliases = '';
}
$table->addRow([
\sprintf('<info>%s</info>', $name),
$command->getDescription(),
$aliases,
]);
}
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$table->render();
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
$this->commandInput = null;
$shellOutput->page(function (OutputInterface $pagedOutput): void {
$this->renderCommandList($pagedOutput);
});
}
return 0;
}
/**
* Render the top-level command list with fixed command widths and a
* conditional alias column when the terminal is wide enough.
*/
private function renderCommandList(OutputInterface $output): void
{
$commands = [];
foreach ($this->getApplication()->all() as $name => $command) {
if ($name !== $command->getName()) {
continue;
}
$commands[] = [
'name' => $name,
'description' => $command->getDescription(),
'aliasText' => $command->getAliases()
? \sprintf('<comment>Aliases:</comment> %s', \implode(', ', $command->getAliases()))
: '',
];
}
$nameWidth = 0;
$aliasWidth = 0;
$descriptionWidth = 0;
$formatter = $output->getFormatter();
foreach ($commands as $command) {
$nameWidth = \max($nameWidth, DisplayString::width($command['name']));
$aliasWidth = \max($aliasWidth, DisplayString::widthWithoutFormatting($command['aliasText'], $formatter));
$descriptionWidth = \max($descriptionWidth, DisplayString::width($command['description']));
}
$terminalWidth = Tty::getWidth();
$wrapper = new ManualWrapper();
$table = $this->getTable($output)->setColumnWidth(0, $nameWidth);
$descriptionWidthWithAliasColumn = $terminalWidth - $nameWidth - $aliasWidth - self::TABLE_OVERHEAD_THREE_COLUMNS;
if ($aliasWidth > 0 && $descriptionWidthWithAliasColumn >= self::MIN_DESCRIPTION_WIDTH_FOR_ALIAS_COLUMN) {
$descriptionColumnWidth = \min($descriptionWidth, $descriptionWidthWithAliasColumn);
$table
->setColumnWidth(1, $descriptionColumnWidth)
->setColumnWidth(2, $aliasWidth);
foreach ($commands as $command) {
$table->addRow([
\sprintf('<info>%s</info>', $command['name']),
$wrapper->wrap($command['description'], $descriptionColumnWidth),
$command['aliasText'],
]);
}
$table->render();
return;
}
$detailsWidth = \max(10, $terminalWidth - $nameWidth - self::TABLE_OVERHEAD_TWO_COLUMNS);
$table->setColumnWidth(1, $detailsWidth);
foreach ($commands as $command) {
$details = $wrapper->wrap($command['description'], $detailsWidth);
if ($command['aliasText'] !== '') {
$details .= "\n".$wrapper->wrap($command['aliasText'], $detailsWidth);
}
$table->addRow([
\sprintf('<info>%s</info>', $command['name']),
$details,
]);
}
$table->render();
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,8 +13,9 @@
use Psy\ConfigPaths;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\Output\ShellOutputAdapter;
use Psy\Readline\Readline;
use Psy\Readline\ReadlineAware;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -25,7 +26,7 @@
*
* Shows, searches and replays readline history. Not too shabby.
*/
class HistoryCommand extends Command
class HistoryCommand extends Command implements ReadlineAware
{
private FilterOptions $filter;
private Readline $readline;
@@ -151,12 +152,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->clearHistory();
$output->writeln('<info>History cleared.</info>');
} else {
$type = $input->getOption('no-numbers') ? 0 : ShellOutput::NUMBER_LINES;
$type = $input->getOption('no-numbers') ? 0 : ShellOutputAdapter::NUMBER_LINES;
if (!$highlighted) {
$type = $type | OutputInterface::OUTPUT_RAW;
}
$output->page($highlighted ?: $history, $type);
$this->shellOutput($output)->page($highlighted ?: $history, $type);
}
return 0;

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -22,7 +22,6 @@
use Psy\Exception\RuntimeException;
use Psy\Input\CodeArgument;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\VarDumper\Presenter;
use Psy\VarDumper\PresenterAware;
use Symfony\Component\Console\Formatter\OutputFormatter;
@@ -118,6 +117,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->validateInput($input);
$this->initEnumerators();
$shellOutput = $this->shellOutput($output);
$method = $input->getOption('long') ? 'writeLong' : 'write';
@@ -127,17 +127,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$reflector = null;
}
// @todo something cleaner than this :-/
if ($output instanceof ShellOutput && $input->getOption('long')) {
$output->startPaging();
if ($input->getOption('long')) {
$shellOutput->startPaging();
}
foreach ($this->enumerators as $enumerator) {
$this->$method($output, $enumerator->enumerate($input, $reflector, $target));
}
if ($output instanceof ShellOutput && $input->getOption('long')) {
$output->stopPaging();
if ($input->getOption('long')) {
$shellOutput->stopPaging();
}
// Set some magic local variables
@@ -186,9 +185,7 @@ protected function write(OutputInterface $output, array $result)
foreach ($result as $label => $items) {
// Pre-format each item individually to avoid O(n^2) performance
// in Symfony's OutputFormatter when processing large strings with many style tags.
$names = \array_map(function ($item) use ($formatter) {
return $formatter->format($this->formatItemName($item));
}, $items);
$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);
@@ -213,9 +210,12 @@ protected function writeLong(OutputInterface $output, array $result)
}
$table = $this->getTable($output);
$first = true;
foreach ($result as $label => $items) {
$output->writeln('');
if (!$first) {
$output->writeln('');
}
$output->writeln(\sprintf('<strong>%s:</strong>', $label));
$table->setRows([]);
@@ -224,6 +224,7 @@ protected function writeLong(OutputInterface $output, array $result)
}
$table->render();
$first = false;
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -95,9 +95,7 @@ protected function filterClasses(string $key, array $classes, bool $internal, bo
}
if (!$user && !$internal) {
$ret[$key] = \array_filter($classes, function ($class) use ($prefix) {
return $prefix === null || \strpos(\strtolower($class), $prefix) === 0;
});
$ret[$key] = \array_filter($classes, fn ($class) => $prefix === null || \strpos(\strtolower($class), $prefix) === 0);
}
return $ret;

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -15,6 +15,7 @@
use Psy\Input\FilterOptions;
use Psy\Util\Mirror;
use Psy\VarDumper\Presenter;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
/**
@@ -30,6 +31,7 @@ abstract class Enumerator
const IS_CONSTANT = 'const';
const IS_CLASS = 'class';
const IS_FUNCTION = 'function';
const IS_VIRTUAL = 'virtual';
private FilterOptions $filter;
private Presenter $presenter;
@@ -91,6 +93,12 @@ protected function showItem($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);
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,6 +11,8 @@
namespace Psy\Command\ListCommand;
use Psy\Reflection\ReflectionMagicMethod;
use Psy\Util\Docblock;
use Symfony\Component\Console\Input\InputInterface;
/**
@@ -59,7 +61,7 @@ protected function listItems(InputInterface $input, ?\Reflector $reflector = nul
* @param \ReflectionClass $reflector
* @param bool $noInherit Exclude inherited methods
*
* @return array
* @return \ReflectionMethod[]
*/
protected function getMethods(bool $showAll, \ReflectionClass $reflector, bool $noInherit = false): array
{
@@ -78,6 +80,18 @@ protected function getMethods(bool $showAll, \ReflectionClass $reflector, bool $
}
}
// 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;
@@ -86,7 +100,7 @@ protected function getMethods(bool $showAll, \ReflectionClass $reflector, bool $
/**
* Prepare formatted method array.
*
* @param array $methods
* @param \ReflectionMethod[] $methods
*
* @return array
*/
@@ -127,10 +141,14 @@ protected function getKindLabel(\ReflectionClass $reflector): string
/**
* Get output style for the given method's visibility.
*
* @param \ReflectionMethod $method
* @param \ReflectionMethod|ReflectionMagicMethod $method
*/
private function getVisibilityStyle(\ReflectionMethod $method): string
private function getVisibilityStyle(\Reflector $method): string
{
if ($method instanceof ReflectionMagicMethod) {
return self::IS_VIRTUAL;
}
if ($method->isPublic()) {
return self::IS_PUBLIC;
} elseif ($method->isProtected()) {

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,6 +11,8 @@
namespace Psy\Command\ListCommand;
use Psy\Reflection\ReflectionMagicProperty;
use Psy\Util\Docblock;
use Symfony\Component\Console\Input\InputInterface;
/**
@@ -60,7 +62,7 @@ protected function listItems(InputInterface $input, ?\Reflector $reflector = nul
* @param \ReflectionClass $reflector
* @param bool $noInherit Exclude inherited properties
*
* @return array
* @return \ReflectionProperty[]
*/
protected function getProperties(bool $showAll, \ReflectionClass $reflector, bool $noInherit = false): array
{
@@ -77,6 +79,18 @@ protected function getProperties(bool $showAll, \ReflectionClass $reflector, boo
}
}
// 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;
@@ -85,7 +99,7 @@ protected function getProperties(bool $showAll, \ReflectionClass $reflector, boo
/**
* Prepare formatted property array.
*
* @param array $properties
* @param \ReflectionProperty[] $properties
*
* @return array
*/
@@ -125,10 +139,14 @@ protected function getKindLabel(\ReflectionClass $reflector): string
/**
* Get output style for the given property's visibility.
*
* @param \ReflectionProperty $property
* @param \ReflectionProperty|ReflectionMagicProperty $property
*/
private function getVisibilityStyle(\ReflectionProperty $property): string
private function getVisibilityStyle(\Reflector $property): string
{
if ($property instanceof ReflectionMagicProperty) {
return self::IS_VIRTUAL;
}
if ($property->isPublic()) {
return self::IS_PUBLIC;
} elseif ($property->isProtected()) {
@@ -141,11 +159,16 @@ private function getVisibilityStyle(\ReflectionProperty $property): string
/**
* Present the $target's current value for a reflection property.
*
* @param \ReflectionProperty $property
* @param mixed $target
* @param \ReflectionProperty|ReflectionMagicProperty $property
* @param mixed $target
*/
protected function presentValue(\ReflectionProperty $property, $target): string
protected function presentValue(\Reflector $property, $target): string
{
// Magic properties use SignatureFormatter for display
if ($property instanceof ReflectionMagicProperty) {
return $this->presentSignature($property);
}
if (!$target) {
return '';
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -11,6 +11,7 @@
namespace Psy\Command;
use PhpParser\Error as PhpParserError;
use PhpParser\Node;
use PhpParser\Parser;
use Psy\Context;
@@ -117,7 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
try {
$nodes = $this->parser->parse($code);
} catch (\PhpParser\Error $e) {
} catch (PhpParserError $e) {
if ($this->parseErrorIsEOF($e)) {
$nodes = $this->parser->parse($code.';');
} else {
@@ -125,14 +126,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}
$output->page($this->presenter->present($nodes, $depth));
$this->shellOutput($output)->page($this->presenter->present($nodes, $depth, Presenter::RAW), OutputInterface::OUTPUT_RAW);
$this->context->setReturnValue($nodes);
return 0;
}
private function parseErrorIsEOF(\PhpParser\Error $e): bool
private function parseErrorIsEOF(PhpParserError $e): bool
{
$msg = $e->getRawMessage();

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -102,6 +102,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
private function writeCodeContext(InputInterface $input, OutputInterface $output)
{
$shellOutput = $this->shellOutput($output);
try {
list($target, $reflector) = $this->getTargetAndReflector($input->getArgument('target'), $output);
} catch (UnexpectedTargetException $e) {
@@ -116,7 +118,7 @@ private function writeCodeContext(InputInterface $input, OutputInterface $output
]);
}
$output->page(CodeFormatter::formatCode($code));
$shellOutput->page(CodeFormatter::formatCode($code));
return;
} else {
@@ -128,7 +130,7 @@ private function writeCodeContext(InputInterface $input, OutputInterface $output
$this->setCommandScopeVariables($reflector);
try {
$output->page(CodeFormatter::format($reflector));
$shellOutput->page(CodeFormatter::format($reflector));
} catch (RuntimeException $e) {
$output->writeln(SignatureFormatter::format($reflector));
throw $e;
@@ -167,9 +169,12 @@ private function writeExceptionContext(InputInterface $input, OutputInterface $o
$this->lastException = $exception;
$this->lastExceptionIndex = $index;
$output->writeln($this->getShell()->formatException($exception));
$output->writeln('--');
$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]);

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -15,6 +15,7 @@
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;
@@ -22,7 +23,7 @@
/**
* Evaluate PHP code, bypassing visibility restrictions.
*/
class SudoCommand extends Command
class SudoCommand extends Command implements ReadlineAware
{
private Readline $readline;
private CodeArgumentParser $parser;

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,11 +13,9 @@
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\Throw_;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\Exception\ThrowUpException;
use Psy\Input\CodeArgument;
@@ -79,8 +77,8 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$args = $this->prepareArgs($input->getArgument('exception'));
$throwStmt = new Expression(new Throw_(new New_(new FullyQualifiedName(ThrowUpException::class), $args)));
$throwCode = $this->printer->prettyPrint([$throwStmt]);
$exception = new New_(new FullyQualifiedName(ThrowUpException::class), $args);
$throwCode = 'throw '.$this->printer->prettyPrintExpr($exception).';';
$shell = $this->getShell();
$shell->addCode($throwCode, !$shell->hasCode());

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -27,8 +27,9 @@ class TimeitCommand extends Command
const RESULT_MSG = '<info>Command took %.6f seconds to complete.</info>';
const AVG_RESULT_MSG = '<info>Command took %.6f seconds on average (%.6f median; %.6f total) to complete.</info>';
// All times stored as nanoseconds!
private static ?int $start = null;
// 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;

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,7 +13,7 @@
use Psy\Formatter\TraceFormatter;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\Output\ShellOutputAdapter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -75,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->filter->bind($input);
$trace = $this->getBacktrace(new \Exception(), $input->getOption('num'), $input->getOption('include-psy'));
$output->page($trace, ShellOutput::NUMBER_LINES);
$this->shellOutput($output)->page($trace, ShellOutputAdapter::NUMBER_LINES);
return 0;
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,7 +13,6 @@
use Psy\ConfigPaths;
use Psy\Formatter\CodeFormatter;
use Psy\Output\ShellOutput;
use Psy\Shell;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -112,6 +111,8 @@ protected function fileInfo(): array
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$shellOutput = $this->shellOutput($output);
$info = $this->fileInfo();
$num = $input->getOption('num');
$lineNum = $info['line'];
@@ -124,16 +125,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$endLine = null;
}
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$shellOutput->startPaging();
$output->writeln(\sprintf('From <info>%s:%s</info>:', ConfigPaths::prettyPath($info['file']), $lineNum));
$output->write(CodeFormatter::formatCode($code, $startLine, $endLine, $lineNum), false);
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
$shellOutput->stopPaging();
return 0;
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -14,7 +14,7 @@
use Psy\Context;
use Psy\ContextAware;
use Psy\Input\FilterOptions;
use Psy\Output\ShellOutput;
use Psy\Output\ShellOutputAdapter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -82,6 +82,7 @@ protected function configure(): void
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))) {
@@ -90,11 +91,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$exception = $this->context->getLastException();
$count = $input->getOption('all') ? \PHP_INT_MAX : \max(3, \pow(2, \strlen($incredulity) + 1));
$shell = $this->getShell();
if ($output instanceof ShellOutput) {
$output->startPaging();
}
$shellOutput->startPaging();
do {
$traceCount = \count($exception->getTrace());
$showLines = $count;
@@ -106,23 +105,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$trace = $this->getBacktrace($exception, $showLines);
$moreLines = $traceCount - \count($trace);
$output->writeln($this->getShell()->formatException($exception));
$output->writeln('--');
$output->write($trace, true, ShellOutput::NUMBER_LINES);
$output->writeln('');
$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
));
$output->writeln('');
}
} while ($exception = $exception->getPrevious());
if ($output instanceof ShellOutput) {
$output->stopPaging();
}
$previous = $exception->getPrevious();
if ($previous !== null) {
$shell->writeSpacer($output);
}
} while ($exception = $previous);
$shellOutput->stopPaging();
return 0;
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -13,13 +13,14 @@
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
class YoloCommand extends Command implements ReadlineAware
{
private Readline $readline;

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
use Psy\Completion\AnalysisResult;
/**
* Interface for commands that provide positional argument completion.
*/
interface CommandArgumentCompletionAware
{
/**
* Whether this command owns completion for the current argument context.
*/
public function supportsArgumentCompletion(AnalysisResult $analysis): bool;
/**
* Return completion candidates for the current command-tail context.
*
* @return string[]
*/
public function getArgumentCompletions(AnalysisResult $analysis): array;
}

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

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
use Psy\Command\Command;
/**
* CommandAware interface.
*
* This interface is used to keep completion sources and matchers up to date
* when commands are added to the Shell.
*/
interface CommandAware
{
/**
* Set the available commands.
*
* @param Command[] $commands
*/
public function setCommands(array $commands);
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy;
use Psy\Command\Command;
/**
* Trait for building a command lookup map (name + aliases Command).
*
* Used by completion sources and refiners that need to resolve a command
* name or alias to its Command instance.
*/
trait CommandMapTrait
{
/** @var array<string, Command> */
private array $commandMap = [];
/**
* Set the available commands.
*
* @param Command[] $commands
*/
public function setCommands(array $commands): void
{
$this->commandMap = [];
foreach ($commands as $command) {
$this->commandMap[$command->getName()] = $command;
foreach ($command->getAliases() as $alias) {
$this->commandMap[$alias] = $command;
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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