glastree_on_gitea
This commit is contained in:
+49
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+149
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+127
@@ -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 (  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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user