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

@@ -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,9 @@
namespace Psy\Util;
use Psy\Reflection\ReflectionMagicMethod;
use Psy\Reflection\ReflectionMagicProperty;
/**
* A docblock representation.
*
@@ -36,6 +39,12 @@ class Docblock
'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;
/**
@@ -104,9 +113,7 @@ protected function setComment(string $comment)
protected static function prefixLength(array $lines): int
{
// find only lines with interesting things
$lines = \array_filter($lines, function ($line) {
return \substr($line, \strspn($line, "* \t\n\r\0\x0B"));
});
$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);
@@ -119,10 +126,10 @@ protected static function prefixLength(array $lines): int
return \strspn($first, "* \t\n\r\0\x0B");
}
// find the longest common substring
// 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]) {
if ($first[$i] !== $last[$i] || $first[$i] === '@') {
return $i;
}
}
@@ -145,9 +152,7 @@ protected function parseComment(string $comment)
// Trim asterisks and whitespace from the beginning and whitespace from the end of lines
$prefixLength = self::prefixLength($comment);
$comment = \array_map(function ($line) use ($prefixLength) {
return \rtrim(\substr($line, $prefixLength));
}, $comment);
$comment = \array_map(fn ($line) => \rtrim(\substr($line, $prefixLength)), $comment);
// Group the lines together by @tags
$blocks = [];
@@ -179,7 +184,7 @@ protected function parseComment(string $comment)
// The tagged block is a vector
$count = \count(self::$vectors[$tag]);
if ($body) {
$parts = \preg_split('/\s+/', $body, $count);
$parts = self::splitOnWhitespace($body, $count);
} else {
$parts = [];
}
@@ -244,4 +249,472 @@ public static function strTag(string $str)
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;
}
}

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

@@ -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
vendor/psy/psysh/src/Util/Tty.php vendored Normal file
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;
}
}