glastree_on_gitea

This commit is contained in:
2026-05-26 08:14:29 +02:00
commit 0bed099d05
9556 changed files with 1186307 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
<?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\Util;
/**
* Utility for checking PHP function availability.
*/
class DependencyChecker
{
/**
* Check if all functions in a list are available (exist and not disabled).
*
* @param string[] $functions List of function names to check
*/
public static function functionsAvailable(array $functions): bool
{
foreach ($functions as $func) {
if (!\function_exists($func)) {
return false;
}
}
return empty(self::functionsDisabled($functions));
}
/**
* Check if any functions in a list are disabled.
*
* @param string[] $functions List of function names to check
*
* @return string[] List of disabled functions
*/
public static function functionsDisabled(array $functions): array
{
$disabled = \array_map('strtolower', \array_map('trim', \explode(',', \ini_get('disable_functions'))));
$disabledFunctions = \array_intersect($functions, $disabled);
return $disabledFunctions;
}
}
+720
View File
@@ -0,0 +1,720 @@
<?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\Util;
use Psy\Reflection\ReflectionMagicMethod;
use Psy\Reflection\ReflectionMagicProperty;
/**
* A docblock representation.
*
* Based on PHP-DocBlock-Parser by Paul Scott:
*
* {@link http://www.github.com/icio/PHP-DocBlock-Parser}
*
* @author Paul Scott <paul@duedil.com>
* @author Justin Hileman <justin@justinhileman.info>
*/
class Docblock
{
/**
* Tags in the docblock that have a whitespace-delimited number of parameters
* (such as `@param type var desc` and `@return type desc`) and the names of
* those parameters.
*
* @var array
*/
public static $vectors = [
'throws' => ['type', 'desc'],
'param' => ['type', 'var', 'desc'],
'return' => ['type', 'desc'],
];
/** @var array<string, ReflectionMagicMethod[]> Cache for getMagicMethods() */
private static array $methodCache = [];
/** @var array<string, ReflectionMagicProperty[]> Cache for getMagicProperties() */
private static array $propertyCache = [];
protected $reflector;
/**
* The description of the symbol.
*
* @var string
*/
public $desc;
/**
* The tags defined in the docblock.
*
* The array has keys which are the tag names (excluding the @) and values
* that are arrays, each of which is an entry for the tag.
*
* In the case where the tag name is defined in {@see DocBlock::$vectors} the
* value within the tag-value array is an array in itself with keys as
* described by {@see DocBlock::$vectors}.
*
* @var array
*/
public $tags;
/**
* The entire DocBlock comment that was parsed.
*
* @var string
*/
public $comment;
/**
* Docblock constructor.
*
* @param \Reflector $reflector
*/
public function __construct(\Reflector $reflector)
{
$this->reflector = $reflector;
if ($reflector instanceof \ReflectionClass || $reflector instanceof \ReflectionClassConstant || $reflector instanceof \ReflectionFunctionAbstract || $reflector instanceof \ReflectionProperty) {
$this->setComment($reflector->getDocComment());
}
}
/**
* Set and parse the docblock comment.
*
* @param string $comment The docblock
*/
protected function setComment(string $comment)
{
$this->desc = '';
$this->tags = [];
$this->comment = $comment;
$this->parseComment($comment);
}
/**
* Find the length of the docblock prefix.
*
* @param array $lines
*
* @return int Prefix length
*/
protected static function prefixLength(array $lines): int
{
// find only lines with interesting things
$lines = \array_filter($lines, fn ($line) => \substr($line, \strspn($line, "* \t\n\r\0\x0B")));
// if we sort the lines, we only have to compare two items
\sort($lines);
$first = \reset($lines);
$last = \end($lines);
// Special case for single-line comments
if (\count($lines) === 1) {
return \strspn($first, "* \t\n\r\0\x0B");
}
// find the longest common substring, but stop before @ (tag marker)
$count = \min(\strlen($first), \strlen($last));
for ($i = 0; $i < $count; $i++) {
if ($first[$i] !== $last[$i] || $first[$i] === '@') {
return $i;
}
}
return $count;
}
/**
* Parse the comment into the component parts and set the state of the object.
*
* @param string $comment The docblock
*/
protected function parseComment(string $comment)
{
// Strip the opening and closing tags of the docblock
$comment = \substr($comment, 3, -2);
// Split into arrays of lines
$comment = \array_filter(\preg_split('/\r?\n\r?/', $comment));
// Trim asterisks and whitespace from the beginning and whitespace from the end of lines
$prefixLength = self::prefixLength($comment);
$comment = \array_map(fn ($line) => \rtrim(\substr($line, $prefixLength)), $comment);
// Group the lines together by @tags
$blocks = [];
$b = -1;
foreach ($comment as $line) {
if (self::isTagged($line)) {
$b++;
$blocks[] = [];
} elseif ($b === -1) {
$b = 0;
$blocks[] = [];
}
$blocks[$b][] = $line;
}
// Parse the blocks
foreach ($blocks as $block => $body) {
$body = \trim(\implode("\n", $body));
if ($block === 0 && !self::isTagged($body)) {
// This is the description block
$this->desc = $body;
} else {
// This block is tagged
$tag = \substr(self::strTag($body), 1);
$body = \ltrim(\substr($body, \strlen($tag) + 2));
if (isset(self::$vectors[$tag])) {
// The tagged block is a vector
$count = \count(self::$vectors[$tag]);
if ($body) {
$parts = self::splitOnWhitespace($body, $count);
} else {
$parts = [];
}
// Default the trailing values
$parts = \array_pad($parts, $count, null);
// Store as a mapped array
$this->tags[$tag][] = \array_combine(self::$vectors[$tag], $parts);
} else {
// The tagged block is only text
$this->tags[$tag][] = $body;
}
}
}
}
/**
* Whether or not a docblock contains a given @tag.
*
* @param string $tag The name of the @tag to check for
*/
public function hasTag(string $tag): bool
{
return \is_array($this->tags) && \array_key_exists($tag, $this->tags);
}
/**
* The value of a tag.
*
* @param string $tag
*
* @return array|null
*/
public function tag(string $tag): ?array
{
return $this->hasTag($tag) ? $this->tags[$tag] : null;
}
/**
* Whether or not a string begins with a @tag.
*
* @param string $str
*/
public static function isTagged(string $str): bool
{
return isset($str[1]) && $str[0] === '@' && !\preg_match('/[^A-Za-z]/', $str[1]);
}
/**
* The tag at the beginning of a string.
*
* @param string $str
*
* @return string|null
*/
public static function strTag(string $str)
{
if (\preg_match('/^@[a-z0-9_]+/', $str, $matches)) {
return $matches[0];
}
return null;
}
/**
* Split a string on whitespace, respecting balanced brackets.
*
* Like preg_split('/\s+/', $str, $limit) but treats `<...>` and `(...)` as atomic,
* so `array<int, string> $foo` splits into ['array<int, string>', '$foo']
* and `find(int $id)` stays as one token instead of being split.
*
* Uses a stack to ensure proper nesting (e.g. `<(>)` won't break).
*
* @param string $str The string to split
* @param int $limit Maximum number of parts (0 = unlimited)
*
* @return string[]
*/
private static function splitOnWhitespace(string $str, int $limit = 0): array
{
static $brackets = ['<' => '>', '(' => ')'];
$parts = [];
$current = '';
$stack = [];
$len = \strlen($str);
$i = 0;
while ($i < $len) {
$char = $str[$i];
if (isset($brackets[$char])) {
// Opening bracket; push onto stack
$stack[] = $char;
$current .= $char;
} elseif (!empty($stack) && $brackets[\end($stack)] === $char) {
// Closing bracket matching the current opener; pop stack
\array_pop($stack);
$current .= $char;
} elseif (empty($stack) && \ctype_space($char)) {
// At top level, whitespace is a delimiter
if ($current !== '') {
$parts[] = $current;
$current = '';
// If we've hit the limit, grab the rest as the final part
if ($limit > 0 && \count($parts) === $limit - 1) {
$rest = \ltrim(\substr($str, $i));
if ($rest !== '') {
$parts[] = $rest;
}
return $parts;
}
}
// Skip consecutive whitespace
while ($i + 1 < $len && \ctype_space($str[$i + 1])) {
$i++;
}
} else {
$current .= $char;
}
$i++;
}
if ($current !== '') {
$parts[] = $current;
}
return $parts;
}
/**
* Get magic methods declared in this docblock via @method tags.
*
* Only works when the docblock was created from a ReflectionClass.
*
* @return ReflectionMagicMethod[]
*/
public function getMethods(): array
{
if (empty($this->comment) || !$this->reflector instanceof \ReflectionClass) {
return [];
}
return self::parseMethodTags($this->comment, $this->reflector);
}
/**
* Get magic properties declared in this docblock via @property tags.
*
* Includes property, property-read, and property-write tags.
* Only works when the docblock was created from a ReflectionClass.
*
* @return ReflectionMagicProperty[]
*/
public function getProperties(): array
{
if (empty($this->comment) || !$this->reflector instanceof \ReflectionClass) {
return [];
}
return self::parsePropertyTags($this->comment, $this->reflector);
}
/**
* Get all magic methods from a class and its parents, interfaces, and traits.
*
* Results are cached for performance.
*
* @return ReflectionMagicMethod[]
*/
public static function getMagicMethods(\ReflectionClass $class): array
{
$cacheKey = $class->getName();
if (isset(self::$methodCache[$cacheKey])) {
return self::$methodCache[$cacheKey];
}
$methods = [];
// Walk parent classes first (so child methods take precedence)
$parent = $class->getParentClass();
if ($parent !== false) {
foreach (self::getMagicMethods($parent) as $method) {
$methods[$method->getName()] = $method;
}
}
// Walk interfaces
foreach ($class->getInterfaces() as $interface) {
foreach (self::getMagicMethods($interface) as $method) {
if (!isset($methods[$method->getName()])) {
$methods[$method->getName()] = $method;
}
}
}
// Walk traits
foreach ($class->getTraits() as $trait) {
foreach (self::getMagicMethods($trait) as $method) {
if (!isset($methods[$method->getName()])) {
$methods[$method->getName()] = $method;
}
}
}
// Parse this class's docblock (takes precedence over inherited)
$docComment = $class->getDocComment();
if ($docComment !== false) {
foreach (self::parseMethodTags($docComment, $class) as $method) {
$methods[$method->getName()] = $method;
}
}
self::$methodCache[$cacheKey] = \array_values($methods);
return self::$methodCache[$cacheKey];
}
/**
* Get all magic properties from a class and its parents, interfaces, and traits.
*
* Results are cached for performance.
*
* @return ReflectionMagicProperty[]
*/
public static function getMagicProperties(\ReflectionClass $class): array
{
$cacheKey = $class->getName();
if (isset(self::$propertyCache[$cacheKey])) {
return self::$propertyCache[$cacheKey];
}
$properties = [];
// Walk parent classes first (so child properties take precedence)
$parent = $class->getParentClass();
if ($parent !== false) {
foreach (self::getMagicProperties($parent) as $property) {
$properties[$property->getName()] = $property;
}
}
// Walk interfaces
foreach ($class->getInterfaces() as $interface) {
foreach (self::getMagicProperties($interface) as $property) {
if (!isset($properties[$property->getName()])) {
$properties[$property->getName()] = $property;
}
}
}
// Walk traits
foreach ($class->getTraits() as $trait) {
foreach (self::getMagicProperties($trait) as $property) {
if (!isset($properties[$property->getName()])) {
$properties[$property->getName()] = $property;
}
}
}
// Parse this class's docblock (takes precedence over inherited)
$docComment = $class->getDocComment();
if ($docComment !== false) {
foreach (self::parsePropertyTags($docComment, $class) as $property) {
$properties[$property->getName()] = $property;
}
}
self::$propertyCache[$cacheKey] = \array_values($properties);
return self::$propertyCache[$cacheKey];
}
/**
* Clear the magic method and property caches.
*
* Useful for testing or when classes are redefined.
*/
public static function clearMagicCache(): void
{
self::$methodCache = [];
self::$propertyCache = [];
}
/**
* Parse a single @method tag body.
*
* Handles: [static] [returnType] [&]methodName([params]) [description]
*
* @return array|null Parsed method data or null if invalid
*/
private static function parseMethodTag(string $body): ?array
{
$body = \trim($body);
if ($body === '') {
return null;
}
$isStatic = false;
$returnType = null;
$returnsReference = false;
$name = null;
$parameters = '';
$description = null;
// Check for 'static' keyword at the start
if (\strpos($body, 'static ') === 0) {
$isStatic = true;
$body = \ltrim(\substr($body, 7));
}
// Split into tokens respecting generics
$tokens = self::splitOnWhitespace($body);
if (empty($tokens)) {
return null;
}
// Find the method name (contains parentheses or is the last non-description token)
$methodIndex = null;
foreach ($tokens as $i => $token) {
if (\preg_match('/^&?[\w_]+\s*\(/', $token)) {
$methodIndex = $i;
break;
}
}
// If no parentheses found, check for bare method name pattern
if ($methodIndex === null) {
foreach ($tokens as $i => $token) {
if (\preg_match('/^&?[\w_]+$/', $token) && $i > 0) {
// Could be a method name if previous tokens look like types
$methodIndex = $i;
break;
}
}
}
// If still no method found, first token might be the method name
if ($methodIndex === null) {
if (\preg_match('/^&?[\w_]+/', $tokens[0])) {
$methodIndex = 0;
} else {
return null;
}
}
// Everything before methodIndex is the return type
if ($methodIndex > 0) {
$returnType = \implode(' ', \array_slice($tokens, 0, $methodIndex));
}
// Parse the method token
$methodToken = $tokens[$methodIndex];
// Check for returns-by-reference
if (\strpos($methodToken, '&') === 0) {
$returnsReference = true;
$methodToken = \substr($methodToken, 1);
}
// Extract method name and parameters
if (\preg_match('/^([\w_]+)\s*(?:\(([^\)]*)\))?/', $methodToken, $matches)) {
$name = $matches[1];
$parameters = $matches[2] ?? '';
} else {
return null;
}
// Everything after the method token is the description
if ($methodIndex < \count($tokens) - 1) {
$descParts = \array_slice($tokens, $methodIndex + 1);
$description = \implode(' ', $descParts);
}
return [
'name' => $name,
'static' => $isStatic,
'returnType' => $returnType,
'returnsReference' => $returnsReference,
'parameters' => $parameters,
'description' => $description !== '' ? $description : null,
];
}
/**
* Parse a single @property tag body.
*
* Handles: [type] $name [description]
*
* @param string $tagName One of 'property', 'property-read', 'property-write'
*
* @return array|null Parsed property data or null if invalid
*/
private static function parsePropertyTag(string $body, string $tagName): ?array
{
$body = \trim($body);
if ($body === '') {
return null;
}
$type = null;
$name = null;
$description = null;
// Split into tokens respecting generics
$tokens = self::splitOnWhitespace($body);
if (empty($tokens)) {
return null;
}
// Find the property name (starts with $ or is a bare word after a type)
$nameIndex = null;
foreach ($tokens as $i => $token) {
if (\strpos($token, '$') === 0) {
$nameIndex = $i;
break;
}
}
// If no $ found, check if second token looks like a name (first would be type)
if ($nameIndex === null && \count($tokens) >= 2) {
if (\preg_match('/^[\w_]+$/', $tokens[1])) {
$nameIndex = 1;
}
}
// If still no name found, first token is the name (no type)
if ($nameIndex === null) {
$nameIndex = 0;
}
// Everything before nameIndex is the type
if ($nameIndex > 0) {
$type = \implode(' ', \array_slice($tokens, 0, $nameIndex));
}
// Extract property name (strip $ if present)
$name = \ltrim($tokens[$nameIndex], '$');
// Everything after the name is the description
if ($nameIndex < \count($tokens) - 1) {
$descParts = \array_slice($tokens, $nameIndex + 1);
$description = \implode(' ', $descParts);
}
return [
'name' => $name,
'type' => $type,
'readOnly' => $tagName === 'property-read',
'writeOnly' => $tagName === 'property-write',
'description' => $description !== '' ? $description : null,
];
}
/**
* Parse all @method tags from a raw docblock comment.
*
* @return ReflectionMagicMethod[]
*/
private static function parseMethodTags(string $docComment, \ReflectionClass $class): array
{
$methods = [];
if (\strpos($docComment, '@method') === false) {
return $methods;
}
// Match @method tags (handles multi-line by stopping at next @ or end)
if (\preg_match_all('/@method\s+(.+?)(?=\n\s*\*\s*@|\n\s*\*\/|\z)/s', $docComment, $matches)) {
foreach ($matches[1] as $body) {
// Clean up the body (remove leading asterisks and normalize whitespace)
$body = \preg_replace('/\n\s*\*\s*/', ' ', $body);
$body = \trim($body);
$parsed = self::parseMethodTag($body);
if ($parsed !== null) {
$methods[] = new ReflectionMagicMethod(
$class,
$parsed['name'],
$parsed['static'],
$parsed['returnType'],
$parsed['parameters'],
$parsed['description'],
$parsed['returnsReference']
);
}
}
}
return $methods;
}
/**
* Parse all @property tags from a raw docblock comment.
*
* @return ReflectionMagicProperty[]
*/
private static function parsePropertyTags(string $docComment, \ReflectionClass $class): array
{
$properties = [];
// Match @property, @property-read, @property-write tags
$pattern = '/@(property(?:-read|-write)?)\s+(.+?)(?=\n\s*\*\s*@|\n\s*\*\/|\z)/s';
if (\preg_match_all($pattern, $docComment, $matches, \PREG_SET_ORDER)) {
foreach ($matches as $match) {
$tagName = $match[1];
$body = \preg_replace('/\n\s*\*\s*/', ' ', $match[2]);
$body = \trim($body);
$parsed = self::parsePropertyTag($body, $tagName);
if ($parsed !== null) {
$properties[] = new ReflectionMagicProperty(
$class,
$parsed['name'],
$parsed['type'],
$parsed['readOnly'],
$parsed['writeOnly'],
$parsed['description']
);
}
}
}
return $properties;
}
}
+31
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\Util;
/**
* A static class to wrap JSON encoding/decoding with PsySH's default options.
*/
class Json
{
/**
* Encode a value as JSON.
*
* @param mixed $val
* @param int $opt
*/
public static function encode($val, int $opt = 0): string
{
$opt |= \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
return \json_encode($val, $opt);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?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\Util;
use Psy\Exception\RuntimeException;
use Psy\Reflection\ReflectionConstant;
use Psy\Reflection\ReflectionNamespace;
/**
* A utility class for getting Reflectors.
*/
class Mirror
{
const CONSTANT = 1;
const METHOD = 2;
const STATIC_PROPERTY = 4;
const PROPERTY = 8;
/**
* Get a Reflector for a function, class or instance, constant, method or property.
*
* Optionally, pass a $filter param to restrict the types of members checked. For example, to only Reflectors for
* static properties and constants, pass:
*
* $filter = Mirror::CONSTANT | Mirror::STATIC_PROPERTY
*
* @throws \Psy\Exception\RuntimeException when a $member specified but not present on $value
* @throws \InvalidArgumentException if $value is something other than an object or class/function name
*
* @param mixed $value Class or function name, or variable instance
* @param string|null $member Optional: property, constant or method name (default: null)
* @param int $filter (default: CONSTANT | METHOD | PROPERTY | STATIC_PROPERTY)
*
* @return \Reflector
*/
public static function get($value, ?string $member = null, int $filter = 15): \Reflector
{
if ($member === null && \is_string($value)) {
if (\function_exists($value)) {
return new \ReflectionFunction($value);
} elseif (\defined($value) || ReflectionConstant::isMagicConstant($value)) {
return new ReflectionConstant($value);
}
}
$class = self::getClass($value);
if ($member === null) {
return $class;
} elseif ($filter & self::CONSTANT && $class->hasConstant($member)) {
return new \ReflectionClassConstant($value, $member);
} elseif ($filter & self::METHOD && $class->hasMethod($member)) {
return $class->getMethod($member);
} elseif ($filter & self::PROPERTY && $class->hasProperty($member)) {
return $class->getProperty($member);
} elseif ($filter & self::STATIC_PROPERTY && $class->hasProperty($member) && $class->getProperty($member)->isStatic()) {
return $class->getProperty($member);
} else {
throw new RuntimeException(\sprintf('Unknown member %s on class %s', $member, \is_object($value) ? \get_class($value) : $value));
}
}
/**
* Get a ReflectionClass (or ReflectionObject, or ReflectionNamespace) if possible.
*
* @throws \InvalidArgumentException if $value is not a namespace or class name or instance
*
* @param mixed $value
*
* @return \ReflectionClass|ReflectionNamespace
*/
private static function getClass($value)
{
if (\is_object($value)) {
return new \ReflectionObject($value);
}
if (!\is_string($value)) {
throw new \InvalidArgumentException('Mirror expects an object or class');
}
if (\class_exists($value) || \interface_exists($value) || \trait_exists($value)) {
return new \ReflectionClass($value);
}
$namespace = \preg_replace('/(^\\\\|\\\\$)/', '', $value);
if (self::namespaceExists($namespace)) {
return new ReflectionNamespace($namespace);
}
throw new \InvalidArgumentException('Unknown namespace, class or function: '.$value);
}
/**
* Check declared namespaces for a given namespace.
*/
private static function namespaceExists(string $value): bool
{
return \in_array(\strtolower($value), self::getDeclaredNamespaces());
}
/**
* Get an array of all currently declared namespaces.
*
* Note that this relies on at least one function, class, interface, trait
* or constant to have been declared in that namespace.
*/
private static function getDeclaredNamespaces(): array
{
$functions = \get_defined_functions();
$allNames = \array_merge(
$functions['internal'],
$functions['user'],
\get_declared_classes(),
\get_declared_interfaces(),
\get_declared_traits(),
\array_keys(\get_defined_constants())
);
$namespaces = [];
foreach ($allNames as $name) {
$chunks = \explode('\\', \strtolower($name));
// the last one is the function or class or whatever...
\array_pop($chunks);
while (!empty($chunks)) {
$namespaces[\implode('\\', $chunks)] = true;
\array_pop($chunks);
}
}
$namespaceNames = \array_keys($namespaces);
\sort($namespaceNames);
return $namespaceNames;
}
}
+127
View File
@@ -0,0 +1,127 @@
<?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\Util;
/**
* String utility methods.
*
* @author ju1ius
*/
class Str
{
const UNVIS_RX = <<<'EOS'
/
\\(?:
((?:040)|s)
| (240)
| (?: M-(.) )
| (?: M\^(.) )
| (?: \^(.) )
)
/xS
EOS;
/**
* Decodes a string encoded by libsd's strvis.
*
* From `man 3 vis`:
*
* Use an M to represent meta characters (characters with the 8th bit set),
* and use a caret ^ to represent control characters (see iscntrl(3)).
* The following formats are used:
*
* \040 Represents ASCII space.
*
* \240 Represents Meta-space (&nbsp in HTML).
*
* \M-C Represents character C with the 8th bit set.
* Spans characters \241 through \376.
*
* \M^C Represents control character C with the 8th bit set.
* Spans characters \200 through \237, and \377 (as \M^?).
*
* \^C Represents the control character C.
* Spans characters \000 through \037, and \177 (as \^?).
*
* The other formats are supported by PHP's stripcslashes,
* except for the \s sequence (ASCII space).
*
* @param string $input The string to decode
*/
public static function unvis(string $input): string
{
$output = \preg_replace_callback(self::UNVIS_RX, [self::class, 'unvisReplace'], $input);
// other escapes & octal are handled by stripcslashes
return \stripcslashes($output);
}
/**
* Callback for Str::unvis.
*
* @param array $match The matches passed by preg_replace_callback
*/
protected static function unvisReplace(array $match): string
{
// \040, \s
if (!empty($match[1])) {
return "\x20";
}
// \240
if (!empty($match[2])) {
return "\xa0";
}
// \M-(.)
if (isset($match[3]) && $match[3] !== '') {
$chr = $match[3];
// unvis S_META1
$cp = 0200;
$cp |= \ord($chr);
return \chr($cp);
}
// \M^(.)
if (isset($match[4]) && $match[4] !== '') {
$chr = $match[4];
// unvis S_META | S_CTRL
$cp = 0200;
$cp |= ($chr === '?') ? 0177 : \ord($chr) & 037;
return \chr($cp);
}
// \^(.)
if (isset($match[5]) && $match[5] !== '') {
$chr = $match[5];
// unvis S_CTRL
$cp = 0;
$cp |= ($chr === '?') ? 0177 : \ord($chr) & 037;
return \chr($cp);
}
}
/**
* Check whether a given string is a valid PHP class name.
*
* Validates that the name follows PHP identifier rules, with optional
* namespace separators.
*
* @param string $name The name to check
*
* @return bool True if the name is syntactically valid
*/
public static function isValidClassName(string $name): bool
{
// Regex based on https://www.php.net/manual/en/language.oop5.basic.php#language.oop5.basic.class
return \preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/', $name) === 1;
}
}
+356
View File
@@ -0,0 +1,356 @@
<?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\Util;
use Psy\Readline\Interactive\Helper\DebugLog;
/**
* Terminal color detection and computation utility.
*
* Queries the terminal's background and foreground colors via OSC escape
* sequences, then computes an appropriate input frame background by blending
* a subtle tint over the detected color.
*/
class TerminalColor
{
/** @var array{bg: int[]|null, fg: int[]|null}|null */
private static ?array $cachedColors = null;
private const OSC_FOREGROUND = '10';
private const OSC_BACKGROUND = '11';
private const QUERY_TIMEOUT_USEC = 50000; // 50ms
/**
* Compute an appropriate input frame background color.
*
* Queries the terminal for its background color, then blends a subtle tint
* on top: white at 12% for dark themes, black at 4% for light themes.
*
* Returns a hex color string (e.g. "#1a1a1a") or null if detection fails.
*/
public static function computeInputFrameBackground(): ?string
{
return self::blendOverTerminalBackground([255, 255, 255], 0.12, [0, 0, 0], 0.04);
}
/**
* Compute a red-tinted input frame background for syntax error feedback.
*
* Blends a subtle red tint over the terminal background: stronger for
* dark themes (~15%), gentler for light themes (~8%).
*/
public static function computeInputFrameErrorBackground(): ?string
{
return self::blendOverTerminalBackground([200, 60, 60], 0.15, [200, 60, 60], 0.08);
}
/**
* Blend an overlay color over the detected terminal background.
*
* @param int[] $darkOverlay Overlay [r, g, b] for dark themes
* @param float $darkAlpha Blend alpha for dark themes
* @param int[] $lightOverlay Overlay [r, g, b] for light themes
* @param float $lightAlpha Blend alpha for light themes
*/
private static function blendOverTerminalBackground(
array $darkOverlay,
float $darkAlpha,
array $lightOverlay,
float $lightAlpha
): ?string {
$colors = self::queryTerminalColors();
if ($colors['bg'] === null) {
DebugLog::log('TerminalColor', 'NO_BG_DETECTED');
return null;
}
$bg = $colors['bg'];
if (self::isLight($bg)) {
$blended = self::blend($lightOverlay, $bg, $lightAlpha);
} else {
$blended = self::blend($darkOverlay, $bg, $darkAlpha);
}
$hex = self::toHex($blended);
DebugLog::log('TerminalColor', 'INPUT_FRAME', [
'theme' => self::isLight($bg) ? 'light' : 'dark',
'bg' => self::toHex($bg),
'blended' => $hex,
]);
return $hex;
}
/**
* Query the terminal's foreground and background colors.
*
* Results are cached for the lifetime of the process.
*
* @return array{bg: int[]|null, fg: int[]|null}
*/
public static function queryTerminalColors(): array
{
if (self::$cachedColors !== null) {
return self::$cachedColors;
}
self::$cachedColors = ['bg' => null, 'fg' => null];
$tty = self::openTty();
if ($tty === null) {
DebugLog::log('TerminalColor', 'SKIP', ['reason' => 'no_tty']);
return self::$cachedColors;
}
$startTime = \microtime(true);
try {
$sttyState = self::saveStty();
if ($sttyState === null) {
DebugLog::log('TerminalColor', 'SKIP', ['reason' => 'stty_failed']);
return self::$cachedColors;
}
try {
// Put terminal in raw mode for direct I/O
@\shell_exec('stty -echo -icanon min 0 time 0 < /dev/tty 2>/dev/null');
// Query background and foreground together
\fwrite($tty, "\033]".self::OSC_BACKGROUND.";?\033\\\033]".self::OSC_FOREGROUND.";?\033\\");
\fflush($tty);
$response = self::readResponse($tty);
$elapsed = (\microtime(true) - $startTime) * 1000;
if ($response !== '') {
self::$cachedColors['bg'] = self::parseOscResponse($response, self::OSC_BACKGROUND);
self::$cachedColors['fg'] = self::parseOscResponse($response, self::OSC_FOREGROUND);
DebugLog::log('TerminalColor', 'OSC_QUERY', [
'elapsed_ms' => \round($elapsed, 1),
'bg' => self::$cachedColors['bg'] !== null ? self::toHex(self::$cachedColors['bg']) : 'null',
'fg' => self::$cachedColors['fg'] !== null ? self::toHex(self::$cachedColors['fg']) : 'null',
'response' => $response,
]);
} else {
DebugLog::log('TerminalColor', 'OSC_QUERY', [
'elapsed_ms' => \round($elapsed, 1),
'result' => 'no_response',
]);
}
} finally {
self::restoreStty($sttyState);
}
} finally {
@\fclose($tty);
}
return self::$cachedColors;
}
/**
* Determine whether an RGB color is light (high luminance).
*
* @param int[] $rgb [r, g, b] values 0-255
*/
public static function isLight(array $rgb): bool
{
return self::luminance($rgb) > 128;
}
/**
* Compute perceived luminance for an RGB color.
*
* Uses the ITU-R BT.601 luma formula.
*
* @param int[] $rgb [r, g, b] values 0-255
*/
public static function luminance(array $rgb): float
{
return 0.299 * $rgb[0] + 0.587 * $rgb[1] + 0.114 * $rgb[2];
}
/**
* Alpha-composite an overlay color onto a base color.
*
* @param int[] $overlay [r, g, b] values 0-255
* @param int[] $base [r, g, b] values 0-255
*
* @return int[] [r, g, b] blended result
*/
public static function blend(array $overlay, array $base, float $alpha): array
{
return [
(int) \round($overlay[0] * $alpha + $base[0] * (1 - $alpha)),
(int) \round($overlay[1] * $alpha + $base[1] * (1 - $alpha)),
(int) \round($overlay[2] * $alpha + $base[2] * (1 - $alpha)),
];
}
/**
* Convert an RGB array to a hex color string.
*
* @param int[] $rgb [r, g, b] values 0-255
*/
public static function toHex(array $rgb): string
{
return \sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/**
* Parse an OSC color response for a given parameter number.
*
* Handles the `rgb:R/G/B`, `rgb:RR/GG/BB`, `rgb:RRR/GGG/BBB`, and
* `rgb:RRRR/GGGG/BBBB` formats returned by xterm-compatible terminals.
*
* @return int[]|null [r, g, b] values 0-255, or null on parse failure
*/
public static function parseOscResponse(string $response, string $param): ?array
{
// Match OSC response: ESC ] <param> ; rgb:R.../G.../B... (terminated by BEL or ST)
$pattern = '/\033\]'.$param.';rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})/';
if (!\preg_match($pattern, $response, $matches)) {
return null;
}
// Components are 1-4 hex digits; normalize each channel to 0-255.
return [
self::scaleColorComponent($matches[1]),
self::scaleColorComponent($matches[2]),
self::scaleColorComponent($matches[3]),
];
}
/**
* Scale a hex color component string to an 8-bit value (0-255).
*
* Components are normalized by bit depth:
* - 1 hex digit (4-bit) 0x0-0xf -> 0-255
* - 2 hex digits (8-bit) 0x00-0xff -> 0-255
* - 3 hex digits (12-bit) 0x000-0xfff -> 0-255
* - 4 hex digits (16-bit) 0x0000-0xffff -> 0-255
*/
private static function scaleColorComponent(string $hex): int
{
$digits = \strlen($hex);
$value = \hexdec($hex);
// 8-bit values are already in the target range.
if ($digits === 2) {
return (int) $value;
}
$maxValue = (16 ** $digits) - 1;
return (int) \round(($value * 255) / $maxValue);
}
/**
* Open /dev/tty for terminal I/O.
*
* @return resource|null
*/
private static function openTty()
{
$tty = @\fopen('/dev/tty', 'r+');
if ($tty === false) {
return null;
}
\stream_set_blocking($tty, false);
return $tty;
}
/**
* Save the current stty state.
*/
private static function saveStty(): ?string
{
$state = @\shell_exec('stty -g < /dev/tty 2>/dev/null');
if (!\is_string($state) || \trim($state) === '') {
return null;
}
return \trim($state);
}
/**
* Restore a previously saved stty state.
*/
private static function restoreStty(string $state): void
{
@\shell_exec(\sprintf('stty %s < /dev/tty 2>/dev/null', \escapeshellarg($state)));
}
/**
* Read the terminal's response with a short timeout.
*
* @param resource $tty
*/
private static function readResponse($tty): string
{
$response = '';
$deadline = \microtime(true) + self::QUERY_TIMEOUT_USEC / 1000000;
while (\microtime(true) < $deadline) {
$read = [$tty];
$write = null;
$except = null;
$remainingUsec = (int) (($deadline - \microtime(true)) * 1000000);
if ($remainingUsec <= 0) {
break;
}
$ready = @\stream_select($read, $write, $except, 0, $remainingUsec);
if ($ready === false || $ready === 0) {
// No data yet; if we already have some response, we're probably done
if ($response !== '') {
break;
}
continue;
}
$chunk = @\fread($tty, 256);
if ($chunk === false || $chunk === '') {
break;
}
$response .= $chunk;
// Stop if we've received both responses (two terminators, ST or BEL)
$terminators = \substr_count($response, "\033\\") + \substr_count($response, "\x07");
if ($terminators >= 2) {
break;
}
}
return $response;
}
/**
* Reset the cached terminal colors.
*
* Primarily for testing.
*/
public static function resetCache(): void
{
self::$cachedColors = null;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?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\Util;
/**
* TTY detection utility.
*/
class Tty
{
private static ?bool $sttySupported = null;
private const DEFAULT_WIDTH = 100;
/**
* Check whether stty is available for terminal manipulation.
*
* Verifies that we're on a Unix-like system with shell_exec and a TTY
* on stdin, and that stty actually works.
*/
public static function supportsStty(): bool
{
if (self::$sttySupported !== null) {
return (bool) self::$sttySupported;
}
if (\PHP_OS_FAMILY === 'Windows' || !\function_exists('shell_exec')) {
return self::$sttySupported = false;
}
if (!\defined('STDIN') || !self::isatty(\STDIN)) {
return self::$sttySupported = false;
}
$stty = @\shell_exec('stty -g 2>/dev/null');
return self::$sttySupported = \is_string($stty) && \trim($stty) !== '';
}
/**
* Check whether a stream is a TTY.
*
* Falls back gracefully when stream_isatty and posix_isatty are
* unavailable, using fstat to check for a character device.
*
* Returns false when detection is uncertain.
*
* @param resource|int $stream
*/
public static function isatty($stream): bool
{
if (\function_exists('stream_isatty')) {
return @\stream_isatty($stream);
}
if (\function_exists('posix_isatty')) {
return @\posix_isatty($stream);
}
// Fallback: check fstat mode for character device (TTY = 0020000)
$stat = @\fstat($stream);
if (!\is_array($stat) || !isset($stat['mode'])) {
return false;
}
return ($stat['mode'] & 0170000) === 0020000;
}
/**
* Get the current terminal width in columns.
*/
public static function getWidth(int $default = self::DEFAULT_WIDTH): int
{
if (self::supportsStty() && \defined('STDOUT') && self::isatty(\STDOUT)) {
// Output format: "rows cols"
$size = @\shell_exec('stty size </dev/tty 2>/dev/null');
if ($size && \preg_match('/^\d+ (\d+)$/', \trim($size), $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);
}
return $default;
}
}