[Console] Add image support to QuestionHelper and #[Ask]

This commit is contained in:
Robin Chalas
2026-02-05 15:24:04 +01:00
parent 5b63cd6a96
commit 08e6d2a6e7
23 changed files with 2178 additions and 17 deletions

View File

@@ -145,10 +145,12 @@ final class ArgumentResolver implements ArgumentResolverInterface
$builtinTypeResolver = new Resolver\BuiltinTypeValueResolver();
$backedEnumResolver = new Resolver\BackedEnumValueResolver();
$dateTimeResolver = new Resolver\DateTimeValueResolver();
$inputFileResolver = new Resolver\InputFileValueResolver();
return [
$backedEnumResolver,
new Resolver\UidValueResolver(),
$inputFileResolver,
$builtinTypeResolver,
new Resolver\MapInputValueResolver($builtinTypeResolver, $backedEnumResolver, $dateTimeResolver),
$dateTimeResolver,

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Input\InputInterface;
/**
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class InputFileValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
$type = $member->getType();
if (!$type instanceof \ReflectionNamedType || InputFile::class !== $type->getName()) {
return [];
}
if ($argument = Argument::tryFrom($member->getMember())) {
return $this->resolveValue($input->getArgument($argument->name), $member);
}
if ($option = Option::tryFrom($member->getMember())) {
return $this->resolveValue($input->getOption($option->name), $member);
}
return [];
}
private function resolveValue(mixed $value, ReflectionMember $member): iterable
{
if (!$value) {
if ($member->isNullable()) {
return [null];
}
return [];
}
if ($value instanceof InputFile) {
return [$value];
}
return [InputFile::fromPath($value)];
}
}

View File

@@ -14,8 +14,10 @@ namespace Symfony\Component\Console\Attribute;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -78,7 +80,28 @@ class Ask implements InteractiveAttributeInterface
return;
}
if ('bool' === $type->getName()) {
$typeName = $type->getName();
if (InputFile::class === $typeName) {
$question = new FileQuestion($self->question);
$question->setValidator($self->validator);
$question->setMaxAttempts($self->maxAttempts);
$value = $io->askQuestion($question);
if (null === $value && !$reflection->isNullable()) {
return;
}
if ($reflection->isProperty()) {
$this->{$reflection->getName()} = $value;
} else {
$input->setArgument($name, $value);
}
return;
}
if ('bool' === $typeName) {
$self->default ??= false;
if (!\is_bool($self->default)) {
@@ -94,7 +117,7 @@ class Ask implements InteractiveAttributeInterface
$question->setTrimmable($self->trimmable);
$question->setTimeout($self->timeout);
if (!$self->validator && $reflection->isProperty() && 'array' !== $type->getName()) {
if (!$self->validator && $reflection->isProperty() && 'array' !== $typeName) {
$self->validator = fn (mixed $value): mixed => $this->{$reflection->getName()} = $value;
}
@@ -103,13 +126,13 @@ class Ask implements InteractiveAttributeInterface
if ($self->normalizer) {
$question->setNormalizer($self->normalizer);
} elseif (is_subclass_of($type->getName(), \BackedEnum::class)) {
} elseif (is_subclass_of($typeName, \BackedEnum::class)) {
/** @var class-string<\BackedEnum> $backedType */
$backedType = $reflection->getType()->getName();
$question->setNormalizer(static fn (string|int $value) => $backedType::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($reflection->getName(), $value, array_column($backedType::cases(), 'value')));
}
if ('array' === $type->getName()) {
if ('array' === $typeName) {
$value = [];
while ($v = $io->askQuestion($question)) {
if ("\x4" === $v || \PHP_EOL === $v || ($question->isTrimmable() && '' === $v = trim($v))) {

View File

@@ -5,6 +5,8 @@ CHANGELOG
---
* [BC BREAK] Add `object` support to input options and arguments' default by changing the `$default` type to `mixed` in `InputArgument`, `InputOption`, `#[Argument]` and `#[Option]`
* Support pasting images with `#[Ask]` on `InputFile` types, supporting Kitty Graphics and iTerm2 protocols
* Add `FileQuestion`, `InputFile`, `InputFileValueResolver`, and `SymfonyStyle::askFile()` for file input handling
* Add `#[AskChoice]` attribute for interactive choice questions in invokable commands
* Add support for method-based commands with `#[AsCommand]` attribute
* Add argument resolver support

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Exception;
/**
* @author Robin Chalas <robin.chalas@gmail.com>
*/
class InvalidFileException extends \RuntimeException implements ExceptionInterface
{
}

244
Helper/FileInputHelper.php Normal file
View File

@@ -0,0 +1,244 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Helper;
use Symfony\Component\Console\Exception\InvalidFileException;
use Symfony\Component\Console\Exception\MissingInputException;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Terminal\Image\ImageProtocolInterface;
use Symfony\Component\Console\Terminal\Image\ITerm2Protocol;
use Symfony\Component\Console\Terminal\Image\KittyGraphicsProtocol;
/**
* Orchestrates file input handling through paste detection or path input.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @internal
*/
final class FileInputHelper
{
private const BPM_ENABLE = "\x1b[?2004h";
private const BPM_DISABLE = "\x1b[?2004l";
private const PASTE_START = "\x1b[200~";
private const PASTE_END = "\x1b[201~";
private ?ImageProtocolInterface $protocol = null;
/**
* @param resource $inputStream
*/
public function readFileInput($inputStream, OutputInterface $output, FileQuestion $question): InputFile
{
if ($canPaste = $question->isPasteAllowed() && Terminal::supportsImageProtocol() && Terminal::hasSttyAvailable()) {
$this->protocol = $this->detectProtocol();
}
$file = null;
$inputHelper = null;
try {
if ($canPaste) {
$inputHelper = new TerminalInputHelper($inputStream);
$output->write(self::BPM_ENABLE);
shell_exec('stty -icanon -echo');
$file = $this->readWithPasteDetection($inputStream, $output, $question, $inputHelper);
} elseif ($question->isPathAllowed()) {
$file = $this->readPathInput($inputStream, $output, $question);
} else {
throw new MissingInputException('Terminal does not support image paste and path input is disabled.');
}
} finally {
if ($canPaste) {
$output->write(self::BPM_DISABLE);
$inputHelper?->finish();
}
}
$this->validateFile($file, $question);
$this->displayFile($output, $file);
return $file;
}
public function displayFile(OutputInterface $output, InputFile $file): void
{
$link = \sprintf('<href=file://%s>%s</>', $file->getRealPath(), $file->getFilename());
if ($output->isVeryVerbose()) {
$output->writeln(\sprintf('<info>%s</info> %s (<comment>%s, %s</comment>)', "\u{1F4CE}", $link, $file->getMimeType() ?? 'unknown', $file->getHumanReadableSize()));
} else {
$output->writeln(\sprintf('<info>%s</info> %s', "\u{1F4CE}", $link));
}
if (Terminal::supportsImageProtocol() && $this->isDisplayableImage($file)) {
$this->displayThumbnail($output, $file);
}
}
/**
* @param resource $inputStream
*/
private function readWithPasteDetection($inputStream, OutputInterface $output, FileQuestion $question, TerminalInputHelper $inputHelper): InputFile
{
$buffer = '';
$inPaste = false;
$pasteBuffer = '';
while (!feof($inputStream)) {
$inputHelper->waitForInput();
$char = fread($inputStream, 1);
if (false === $char || '' === $char) {
if ('' === $buffer && '' === $pasteBuffer) {
throw new MissingInputException('Aborted.');
}
break;
}
$buffer .= $char;
if (!$inPaste && str_ends_with($buffer, self::PASTE_START)) {
$inPaste = true;
$buffer = substr($buffer, 0, -\strlen(self::PASTE_START));
continue;
}
if ($inPaste && str_ends_with($buffer, self::PASTE_END)) {
$pasteBuffer = substr($buffer, 0, -\strlen(self::PASTE_END));
break;
}
if (!$inPaste && ("\n" === $char || "\r" === $char)) {
$buffer = rtrim($buffer, "\r\n");
break;
}
}
if ('' !== $pasteBuffer) {
if (null !== $this->protocol && $this->protocol->detectPastedImage($pasteBuffer)) {
$decoded = $this->protocol->decode($pasteBuffer);
if ('' !== $decoded['data']) {
return InputFile::fromData($decoded['data'], $decoded['format']);
}
}
$path = trim($pasteBuffer);
if ('' !== $path && $question->isPathAllowed()) {
return InputFile::fromPath($path);
}
}
$path = trim($buffer);
if ('' !== $path && $question->isPathAllowed()) {
return InputFile::fromPath($path);
}
throw new MissingInputException('No file input provided.');
}
/**
* @param resource $inputStream
*/
private function readPathInput($inputStream, OutputInterface $output, FileQuestion $question): InputFile
{
if (!$isBlocked = stream_get_meta_data($inputStream)['blocked'] ?? true) {
stream_set_blocking($inputStream, true);
}
$path = fgets($inputStream);
if (!$isBlocked) {
stream_set_blocking($inputStream, false);
}
if (false === $path) {
throw new MissingInputException('Aborted.');
}
if ('' === $path = trim($path)) {
throw new MissingInputException('No file path provided.');
}
return InputFile::fromPath($path);
}
private function validateFile(InputFile $file, FileQuestion $question): void
{
if (!$file->isValid()) {
throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname()));
}
if (null !== $question->getMaxFileSize() && $file->getSize() > $question->getMaxFileSize()) {
throw new InvalidFileException(\sprintf('File "%s" is too large (%s). Maximum allowed size is %s.', $file->getFilename(), $file->getHumanReadableSize(), $this->formatBytes($question->getMaxFileSize())));
}
if (!$question->getAllowedMimeTypes()) {
return;
}
$mimeType = $file->getMimeType();
if (null === $mimeType || !$question->isMimeTypeAllowed($mimeType)) {
throw new InvalidFileException(\sprintf('File "%s" has MIME type "%s" which is not allowed. Allowed types: %s.', $file->getFilename(), $mimeType ?? 'unknown', implode(', ', $question->getAllowedMimeTypes())));
}
}
private function detectProtocol(): ?ImageProtocolInterface
{
if (Terminal::supportsKittyGraphics()) {
return new KittyGraphicsProtocol();
}
if (Terminal::supportsITerm2Images()) {
return new ITerm2Protocol();
}
return null;
}
private function isDisplayableImage(InputFile $file): bool
{
if (null === $mimeType = $file->getMimeType()) {
return false;
}
return str_starts_with($mimeType, 'image/');
}
private function displayThumbnail(OutputInterface $output, InputFile $file): void
{
try {
$contents = $file->getContents();
} catch (InvalidFileException) {
return;
}
$protocol = Terminal::supportsKittyGraphics() ? new KittyGraphicsProtocol() : new ITerm2Protocol();
$output->write($protocol->encode($contents, 16));
$output->writeln('');
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
$power = min($power, \count($units) - 1);
return \sprintf('%.1f %s', $bytes / (1024 ** $power), $units[$power]);
}
}

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
@@ -98,6 +99,12 @@ class QuestionHelper extends Helper
*/
private function doAsk($inputStream, OutputInterface $output, Question $question): mixed
{
if ($question instanceof FileQuestion) {
$this->writePrompt($output, $question);
return (new FileInputHelper())->readFileInput($inputStream, $output, $question);
}
$this->writePrompt($output, $question);
$autocomplete = $question->getAutocompleterCallback();

256
Input/File/InputFile.php Normal file
View File

@@ -0,0 +1,256 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Input\File;
use Symfony\Component\Console\Exception\InvalidFileException;
use Symfony\Component\Mime\MimeTypes;
/**
* Represents a file provided through console input.
*
* Inspired by HttpFoundation's UploadedFile, this class wraps a file provided
* through console input (either pasted via terminal image protocols or typed as a path).
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class InputFile extends \SplFileInfo
{
/** @var string[] */
private static array $tempFiles = [];
private static bool $shutdownRegistered = false;
private ?string $mimeType = null;
private bool $isTempFile;
public function __construct(
string $path,
bool $isTempFile = false,
?string $mimeType = null,
) {
parent::__construct($path);
$this->isTempFile = $isTempFile;
$this->mimeType = $mimeType;
if ($isTempFile) {
if (!self::$shutdownRegistered) {
register_shutdown_function(self::cleanupAll(...));
self::$shutdownRegistered = true;
}
self::$tempFiles[$path] = $path;
}
}
/**
* @throws InvalidFileException when the temporary file cannot be created
*/
public static function fromData(string $data, ?string $format = null): self
{
$extension = $format ? '.'.$format : '';
$tempPath = sys_get_temp_dir().'/symfony_input_'.bin2hex(random_bytes(8)).$extension;
if (false === @file_put_contents($tempPath, $data)) {
throw new InvalidFileException(\sprintf('Failed to create temporary file at "%s".', $tempPath));
}
return new self($tempPath, true);
}
/**
* @throws InvalidFileException when the file does not exist
*/
public static function fromPath(string $path): self
{
$path = self::normalizePath($path);
if (!file_exists($path)) {
throw new InvalidFileException(\sprintf('File "%s" does not exist.', $path));
}
return new self($path, false);
}
private static function normalizePath(string $path): string
{
$path = trim($path);
if (
(str_starts_with($path, '"') && str_ends_with($path, '"'))
|| (str_starts_with($path, "'") && str_ends_with($path, "'"))
) {
$path = substr($path, 1, -1);
}
if (str_starts_with($path, 'file://')) {
$path = urldecode(substr($path, 7));
if ('\\' === \DIRECTORY_SEPARATOR && preg_match('#^/[a-zA-Z]:/#', $path)) {
$path = substr($path, 1);
}
}
// Remove backslash escapes (e.g., "\ " for escaped spaces) on non-Windows systems
if ('\\' !== \DIRECTORY_SEPARATOR) {
$path = preg_replace('/\\\\(.)/', '$1', $path) ?? $path;
}
return $path;
}
public function getMimeType(): ?string
{
if (null !== $this->mimeType) {
return $this->mimeType;
}
if (!$this->isValid()) {
return null;
}
if (class_exists(MimeTypes::class)) {
return $this->mimeType = MimeTypes::getDefault()->guessMimeType($this->getPathname());
}
$finfo = new \finfo(\FILEINFO_MIME_TYPE);
return $this->mimeType = $finfo->file($this->getPathname()) ?: null;
}
public function guessExtension(): ?string
{
$mimeType = $this->getMimeType();
if (null === $mimeType) {
return null;
}
if (class_exists(MimeTypes::class)) {
$extensions = MimeTypes::getDefault()->getExtensions($mimeType);
return $extensions[0] ?? null;
}
return match ($mimeType) {
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
'application/pdf' => 'pdf',
'text/plain' => 'txt',
default => null,
};
}
/**
* @throws InvalidFileException when the file is invalid or the move/copy operation fails
*/
public function move(string $directory, ?string $name = null): self
{
if (!$this->isValid()) {
throw new InvalidFileException('Cannot move an invalid file.');
}
$name ??= $this->getFilename();
$target = rtrim($directory, '/\\').\DIRECTORY_SEPARATOR.$name;
if (!is_dir($directory)) {
if (false === @mkdir($directory, 0o777, true) && !is_dir($directory)) {
throw new InvalidFileException(\sprintf('Unable to create the "%s" directory.', $directory));
}
} elseif (!is_writable($directory)) {
throw new InvalidFileException(\sprintf('Unable to write in the "%s" directory.', $directory));
}
if ($this->isTempFile) {
if (!@rename($this->getPathname(), $target)) {
throw new InvalidFileException(\sprintf('Could not move the file "%s" to "%s".', $this->getPathname(), $target));
}
unset(self::$tempFiles[$this->getPathname()]);
} else {
if (!@copy($this->getPathname(), $target)) {
throw new InvalidFileException(\sprintf('Could not copy the file "%s" to "%s".', $this->getPathname(), $target));
}
}
@chmod($target, 0o666 & ~umask());
return new self($target, false, $this->mimeType);
}
public function cleanup(): void
{
if (!$this->isTempFile) {
return;
}
$path = $this->getPathname();
if (file_exists($path)) {
@unlink($path);
}
unset(self::$tempFiles[$path]);
}
/**
* @internal
*/
public static function cleanupAll(): void
{
foreach (self::$tempFiles as $path) {
if (file_exists($path)) {
@unlink($path);
}
}
self::$tempFiles = [];
}
public function isValid(): bool
{
return is_file($this->getPathname()) && is_readable($this->getPathname());
}
public function isTempFile(): bool
{
return $this->isTempFile;
}
/**
* @throws InvalidFileException when the file is invalid or cannot be read
*/
public function getContents(): string
{
if (!$this->isValid()) {
throw new InvalidFileException('Cannot read an invalid file.');
}
$contents = @file_get_contents($this->getPathname());
if (false === $contents) {
throw new InvalidFileException(\sprintf('Could not read file "%s".', $this->getPathname()));
}
return $contents;
}
public function getHumanReadableSize(): string
{
$size = $this->getSize();
$units = ['B', 'KB', 'MB', 'GB'];
$power = $size > 0 ? floor(log($size, 1024)) : 0;
$power = min($power, \count($units) - 1);
return \sprintf('%.1f %s', $size / (1024 ** $power), $units[$power]);
}
}

94
Question/FileQuestion.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Question;
use Symfony\Component\Console\Exception\InvalidArgumentException;
/**
* Represents a question that accepts file input (paste or path).
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
class FileQuestion extends Question
{
/** @var string[] */
private array $allowedMimeTypes;
private ?int $maxFileSize;
private bool $allowPaste;
private bool $allowPath;
public function __construct(
string $question,
array $allowedMimeTypes = [],
?int $maxFileSize = 5 * 1024 * 1024,
bool $allowPaste = true,
bool $allowPath = true,
) {
parent::__construct($question);
if (!$allowPaste && !$allowPath) {
throw new InvalidArgumentException('At least one of allowPaste or allowPath must be true.');
}
$this->allowedMimeTypes = $allowedMimeTypes;
$this->maxFileSize = $maxFileSize;
$this->allowPaste = $allowPaste;
$this->allowPath = $allowPath;
$this->setTrimmable(false);
}
/**
* @return string[]
*/
public function getAllowedMimeTypes(): array
{
return $this->allowedMimeTypes;
}
public function getMaxFileSize(): ?int
{
return $this->maxFileSize;
}
public function isPasteAllowed(): bool
{
return $this->allowPaste;
}
public function isPathAllowed(): bool
{
return $this->allowPath;
}
public function isMimeTypeAllowed(string $mimeType): bool
{
if (!$this->allowedMimeTypes) {
return true;
}
foreach ($this->allowedMimeTypes as $allowedType) {
if ($mimeType === $allowedType) {
return true;
}
if (str_ends_with($allowedType, '/*')) {
$prefix = substr($allowedType, 0, -1);
if (str_starts_with($mimeType, $prefix)) {
return true;
}
}
}
return false;
}
}

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Helper\TreeHelper;
use Symfony\Component\Console\Helper\TreeNode;
use Symfony\Component\Console\Helper\TreeStyle;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
@@ -31,6 +32,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\TrimmedBufferOutput;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\FileQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Terminal;
@@ -247,6 +249,11 @@ class SymfonyStyle extends OutputStyle
return $this->askQuestion($questionChoice);
}
public function askFile(string $question, array $allowedMimeTypes = []): ?InputFile
{
return $this->askQuestion(new FileQuestion($question, $allowedMimeTypes));
}
public function progressStart(int $max = 0): void
{
$this->progressBar = $this->createProgressBar($max);

View File

@@ -21,6 +21,8 @@ class Terminal
private static ?int $width = null;
private static ?int $height = null;
private static ?bool $stty = null;
private static ?bool $kittyGraphics = null;
private static ?bool $iterm2Images = null;
/**
* About Ansi color types: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
@@ -80,9 +82,6 @@ class Terminal
self::$colorMode = $colorMode;
}
/**
* Gets the terminal width.
*/
public function getWidth(): int
{
$width = getenv('COLUMNS');
@@ -97,9 +96,6 @@ class Terminal
return self::$width ?: 80;
}
/**
* Gets the terminal height.
*/
public function getHeight(): int
{
$height = getenv('LINES');
@@ -131,6 +127,56 @@ class Terminal
return self::$stty = (bool) shell_exec('stty 2> '.('\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null'));
}
public static function supportsKittyGraphics(): bool
{
if (null !== self::$kittyGraphics) {
return self::$kittyGraphics;
}
$termProgram = getenv('TERM_PROGRAM') ?: '';
if (\in_array($termProgram, ['kitty', 'WezTerm', 'ghostty'], true)) {
return self::$kittyGraphics = true;
}
if (str_contains(getenv('TERM') ?: '', 'kitty')) {
return self::$kittyGraphics = true;
}
if (false !== getenv('GHOSTTY_RESOURCES_DIR')) {
return self::$kittyGraphics = true;
}
if (false !== getenv('KONSOLE_VERSION')) {
return self::$kittyGraphics = true;
}
return self::$kittyGraphics = false;
}
public static function supportsITerm2Images(): bool
{
if (null !== self::$iterm2Images) {
return self::$iterm2Images;
}
return self::$iterm2Images = 'iTerm.app' === getenv('TERM_PROGRAM');
}
public static function supportsImageProtocol(): bool
{
return self::supportsKittyGraphics() || self::supportsITerm2Images();
}
public static function setKittyGraphicsSupport(?bool $supported): void
{
self::$kittyGraphics = $supported;
}
public static function setITerm2ImagesSupport(?bool $supported): void
{
self::$iterm2Images = $supported;
}
private static function initDimensions(): void
{
if ('\\' === \DIRECTORY_SEPARATOR) {
@@ -154,9 +200,6 @@ class Terminal
}
}
/**
* Initializes dimensions using the output of an stty columns line.
*/
private static function initDimensionsUsingStty(): void
{
if ($sttyString = self::getSttyColumns()) {
@@ -188,9 +231,6 @@ class Terminal
return [(int) $matches[2], (int) $matches[1]];
}
/**
* Runs and parses stty -a if it's available, suppressing any error output.
*/
private static function getSttyColumns(): ?string
{
return self::readFromProcess(['stty', '-a']);

View File

@@ -0,0 +1,103 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Terminal\Image;
/**
* Handles the iTerm2 Inline Images Protocol (OSC 1337).
*
* The iTerm2 protocol uses Operating System Command (OSC) sequences:
* - Start: ESC ] 1337 ; File= (0x1B 0x5D 0x31 0x33 0x33 0x37 0x3B 0x46 0x69 0x6C 0x65 0x3D)
* - End: BEL (0x07) or ESC \ (0x1B 0x5C)
*
* Format: ESC]1337;File=[arguments]:[base64 data]BEL
*
* @see https://iterm2.com/documentation-images.html
*
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @internal
*/
final class ITerm2Protocol implements ImageProtocolInterface
{
public const OSC_START = "\x1b]1337;File=";
public const BEL = "\x07";
public const ST = "\x1b\\";
public function detectPastedImage(string $data): bool
{
return str_contains($data, self::OSC_START);
}
public function decode(string $data): array
{
if (false === $start = strpos($data, self::OSC_START)) {
return ['data' => '', 'format' => null];
}
if (false === $end = strpos($data, self::BEL, $start)) {
$end = strpos($data, self::ST, $start);
}
if (false === $end) {
return ['data' => '', 'format' => null];
}
$content = substr($data, $start + \strlen(self::OSC_START), $end - $start - \strlen(self::OSC_START));
if (false === $colonPos = strpos($content, ':')) {
return ['data' => '', 'format' => null];
}
$payload = substr($content, $colonPos + 1);
if (false === $decodedData = base64_decode($payload, true)) {
return ['data' => '', 'format' => null];
}
$format = $this->detectImageFormat($decodedData);
return ['data' => $decodedData, 'format' => $format];
}
public function encode(string $imageData, ?int $maxWidth = null): string
{
$arguments = [
'inline=1',
];
if ($maxWidth) {
$arguments[] = \sprintf('width=%d', $maxWidth);
}
$arguments[] = 'preserveAspectRatio=1';
$argumentString = implode(';', $arguments);
$payload = base64_encode($imageData);
return self::OSC_START.$argumentString.':'.$payload.self::BEL;
}
public function getName(): string
{
return 'iterm2';
}
private function detectImageFormat(string $data): ?string
{
return match (true) {
str_starts_with($data, "\x89PNG\r\n\x1a\n") => 'png',
str_starts_with($data, "\xFF\xD8\xFF") => 'jpg',
str_starts_with($data, 'GIF87a'), str_starts_with($data, 'GIF89a') => 'gif',
str_starts_with($data, 'RIFF') && 'WEBP' === substr($data, 8, 4) => 'webp',
default => null,
};
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Terminal\Image;
/**
* Contract for terminal image protocol handlers.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @internal
*/
interface ImageProtocolInterface
{
public function detectPastedImage(string $data): bool;
/**
* @return array{data: string, format: string|null}
*/
public function decode(string $data): array;
public function encode(string $imageData, ?int $maxWidth = null): string;
public function getName(): string;
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Terminal\Image;
/**
* Handles the Kitty Graphics Protocol for terminal image paste/display.
*
* The Kitty Graphics Protocol uses Application Programming Command (APC) sequences:
* - Start: ESC _ G (0x1B 0x5F 0x47)
* - End: ESC \ (0x1B 0x5C) - also known as ST (String Terminator)
*
* Format: ESC_G<control data>;<payload>ESC\
*
* @see https://sw.kovidgoyal.net/kitty/graphics-protocol/
*
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @internal
*/
final class KittyGraphicsProtocol implements ImageProtocolInterface
{
public const APC_START = "\x1b_G";
public const ST = "\x1b\\";
public function detectPastedImage(string $data): bool
{
return str_contains($data, self::APC_START);
}
public function decode(string $data): array
{
if (false === $start = strpos($data, self::APC_START)) {
return ['data' => '', 'format' => null];
}
if (false === $end = strpos($data, self::ST, $start)) {
$end = strpos($data, "\x07", $start);
}
if (false === $end) {
return ['data' => '', 'format' => null];
}
$content = substr($data, $start + \strlen(self::APC_START), $end - $start - \strlen(self::APC_START));
if (false === $semicolonPos = strpos($content, ';')) {
return ['data' => '', 'format' => null];
}
$controlData = substr($content, 0, $semicolonPos);
$payload = substr($content, $semicolonPos + 1);
$decodedData = base64_decode($payload, true);
if (false === $decodedData) {
return ['data' => '', 'format' => null];
}
return ['data' => $decodedData, 'format' => $this->parseFormat($controlData)];
}
public function encode(string $imageData, ?int $maxWidth = null): string
{
$format = $this->detectImageFormat($imageData);
$controlParts = ['a=T', 'f=100'];
if (null !== $maxWidth) {
$controlParts[] = \sprintf('c=%d', $maxWidth);
}
if ('png' === $format) {
$controlParts[1] = 'f=100';
}
$controlData = implode(',', $controlParts);
$payload = base64_encode($imageData);
$maxChunkSize = 4096;
if (\strlen($payload) <= $maxChunkSize) {
return self::APC_START.$controlData.';'.$payload.self::ST;
}
$chunks = str_split($payload, $maxChunkSize);
$result = '';
foreach ($chunks as $i => $chunk) {
$isLast = ($i === \count($chunks) - 1);
$chunkControl = $i > 0 ? 'm='.($isLast ? '0' : '1') : $controlData.',m='.($isLast ? '0' : '1');
$result .= self::APC_START.$chunkControl.';'.$chunk.self::ST;
}
return $result;
}
public function getName(): string
{
return 'kitty';
}
private function parseFormat(string $controlData): ?string
{
foreach (explode(',', $controlData) as $pair) {
$parts = explode('=', $pair, 2);
if (2 === \count($parts) && 'f' === $parts[0]) {
return match ($parts[1]) {
'24' => 'rgb',
'32' => 'rgba',
'100' => 'png',
default => null,
};
}
}
return null;
}
private function detectImageFormat(string $data): ?string
{
return match (true) {
str_starts_with($data, "\x89PNG\r\n\x1a\n") => 'png',
str_starts_with($data, "\xFF\xD8\xFF") => 'jpg',
str_starts_with($data, 'GIF87a'), str_starts_with($data, 'GIF89a') => 'gif',
str_starts_with($data, 'RIFF') && 'WEBP' === substr($data, 8, 4) => 'webp',
default => null,
};
}
}

View File

@@ -0,0 +1,259 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\InputFileValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidFileException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Console\Tests\Fixtures\InvokableWithInputFileTestCommand;
use Symfony\Component\Filesystem\Filesystem;
class InputFileValueResolverTest extends TestCase
{
private Filesystem $filesystem;
private string $tempDir;
protected function setUp(): void
{
$this->filesystem = new Filesystem();
$this->tempDir = sys_get_temp_dir().\DIRECTORY_SEPARATOR.microtime(true).'.'.mt_rand();
mkdir($this->tempDir, 0o777, true);
}
protected function tearDown(): void
{
$this->filesystem->remove($this->tempDir);
}
public function testUnsupportedArgumentType()
{
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => '/some/path'], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] string $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$this->assertSame([], $resolver->resolve('file', $input, $member));
}
public function testRequiresExplicitAttribute()
{
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => '/some/path'], new InputDefinition([
new InputArgument('file'),
]));
// No #[Argument] or #[Option] attribute
$function = static fn (InputFile $file) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$this->assertSame([], $resolver->resolve('file', $input, $member));
}
public function testResolvesInputFileFromPath()
{
$path = $this->tempDir.'/test.txt';
file_put_contents($path, 'test content');
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => $path], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] InputFile $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('file', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(InputFile::class, $results[0]);
$this->assertSame('test content', $results[0]->getContents());
}
public function testNullableWithEmptyArgument()
{
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => ''], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] ?InputFile $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('file', $input, $member);
$this->assertCount(1, $results);
$this->assertNull($results[0]);
}
public function testNullableWithNullArgument()
{
$resolver = new InputFileValueResolver();
$input = new ArrayInput([], new InputDefinition([
new InputArgument('file', InputArgument::OPTIONAL),
]));
$command = new class {
public function __invoke(#[Argument] ?InputFile $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('file', $input, $member);
$this->assertCount(1, $results);
$this->assertNull($results[0]);
}
public function testWithOption()
{
$path = $this->tempDir.'/test.txt';
file_put_contents($path, 'test content');
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['--file' => $path], new InputDefinition([
new InputOption('file', null, InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(#[Option] ?InputFile $file = null)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('file', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(InputFile::class, $results[0]);
$this->assertSame('test content', $results[0]->getContents());
}
public function testAlreadyResolvedInputFile()
{
$path = $this->tempDir.'/test.txt';
file_put_contents($path, 'test content');
$inputFile = InputFile::fromPath($path);
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => $inputFile], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] InputFile $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('file', $input, $member);
$this->assertCount(1, $results);
$this->assertSame($inputFile, $results[0]);
}
public function testInvalidFileThrowsException()
{
$resolver = new InputFileValueResolver();
$input = new ArrayInput(['file' => '/non/existent/file.txt'], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] InputFile $file)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$this->expectException(InvalidFileException::class);
$this->expectExceptionMessage('does not exist');
$resolver->resolve('file', $input, $member);
}
public function testArgumentResolverResolvesInputFile()
{
$path = $this->tempDir.'/test.txt';
file_put_contents($path, 'test content');
$input = new ArrayInput(['file' => $path], new InputDefinition([
new InputArgument('file'),
]));
$command = new class {
public function __invoke(#[Argument] InputFile $file): string
{
return $file->getContents();
}
};
$resolver = new ArgumentResolver(ArgumentResolver::getDefaultArgumentValueResolvers());
$arguments = $resolver->getArguments($input, $command);
$this->assertCount(1, $arguments);
$this->assertInstanceOf(InputFile::class, $arguments[0]);
$this->assertSame('test content', $arguments[0]->getContents());
}
public function testResolvesInputFileInNonInteractiveMode()
{
$tester = new CommandTester(new InvokableWithInputFileTestCommand());
$tester->execute(['file' => __FILE__], ['interactive' => false]);
$tester->assertCommandIsSuccessful();
$this->assertStringContainsString('Filename: InputFileValueResolverTest.php', $tester->getDisplay());
$this->assertStringContainsString('Valid: yes', $tester->getDisplay());
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Fixtures;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:input-file')]
class InvokableWithInputFileTestCommand
{
public function __invoke(
OutputInterface $output,
#[Argument]
#[Ask('Provide a file:')]
InputFile $file,
): int {
$output->writeln('Filename: '.$file->getFilename());
$output->writeln('Valid: '.($file->isValid() ? 'yes' : 'no'));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,305 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Input\File;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Exception\InvalidFileException;
use Symfony\Component\Console\Input\File\InputFile;
use Symfony\Component\Filesystem\Filesystem;
class InputFileTest extends TestCase
{
private Filesystem $filesystem;
private string $tempDir;
protected function setUp(): void
{
$this->filesystem = new Filesystem();
$this->tempDir = sys_get_temp_dir().\DIRECTORY_SEPARATOR.microtime(true).'.'.mt_rand();
mkdir($this->tempDir, 0o777, true);
}
protected function tearDown(): void
{
$this->filesystem->remove($this->tempDir);
InputFile::cleanupAll();
}
public function testFromDataCreatesTemporaryFile()
{
$data = 'test content';
$file = InputFile::fromData($data);
$this->assertTrue($file->isValid());
$this->assertTrue($file->isTempFile());
$this->assertSame($data, $file->getContents());
$this->assertStringStartsWith(sys_get_temp_dir().'/symfony_input_', $file->getPathname());
}
public function testFromDataWithFormat()
{
$data = 'test content';
$file = InputFile::fromData($data, 'txt');
$this->assertStringEndsWith('.txt', $file->getPathname());
}
public function testFromPathWithExistingFile()
{
$path = $this->tempDir.'/test.txt';
file_put_contents($path, 'test content');
$file = InputFile::fromPath($path);
$this->assertTrue($file->isValid());
$this->assertFalse($file->isTempFile());
$this->assertSame('test content', $file->getContents());
}
public function testFromPathWithNonExistentFile()
{
$this->expectException(InvalidFileException::class);
$this->expectExceptionMessage('does not exist');
InputFile::fromPath('/non/existent/file.txt');
}
public function testFromPathWithSpacesInName()
{
$path = $this->tempDir.'/my file with spaces.txt';
file_put_contents($path, 'test content');
$file = InputFile::fromPath($path);
$this->assertTrue($file->isValid());
$this->assertSame('test content', $file->getContents());
}
public function testFromPathWithDoubleQuotes()
{
$path = $this->tempDir.'/quoted file.txt';
file_put_contents($path, 'test content');
// Path wrapped in double quotes (common when dragging files)
$file = InputFile::fromPath('"'.$path.'"');
$this->assertTrue($file->isValid());
$this->assertSame(realpath($path), $file->getRealPath());
}
public function testFromPathWithSingleQuotes()
{
$path = $this->tempDir.'/quoted file.txt';
file_put_contents($path, 'test content');
// Path wrapped in single quotes
$file = InputFile::fromPath("'".$path."'");
$this->assertTrue($file->isValid());
$this->assertSame(realpath($path), $file->getRealPath());
}
public function testFromPathWithEscapedSpaces()
{
if ('\\' === \DIRECTORY_SEPARATOR) {
$this->markTestSkipped('Backslash-escaped spaces are not applicable on Windows.');
}
$path = $this->tempDir.'/escaped file.txt';
file_put_contents($path, 'test content');
// Path with backslash-escaped spaces (common in shell)
$escapedPath = str_replace(' ', '\\ ', $path);
$file = InputFile::fromPath($escapedPath);
$this->assertTrue($file->isValid());
$this->assertSame(realpath($path), $file->getRealPath());
}
public function testFromPathWithFileUri()
{
$path = $this->tempDir.'/uri file.txt';
file_put_contents($path, 'test content');
// file:// URI (common when dragging from some applications)
$file = InputFile::fromPath('file://'.$path);
$this->assertTrue($file->isValid());
$this->assertSame(realpath($path), $file->getRealPath());
}
public function testFromPathWithFileUriAndEncodedSpaces()
{
$path = $this->tempDir.'/uri encoded file.txt';
file_put_contents($path, 'test content');
// file:// URI with URL-encoded spaces
$encodedPath = 'file://'.str_replace(' ', '%20', $path);
$file = InputFile::fromPath($encodedPath);
$this->assertTrue($file->isValid());
$this->assertSame(realpath($path), $file->getRealPath());
}
#[RequiresPhpExtension('fileinfo')]
public function testGetMimeType()
{
// Create a minimal PNG file (1x1 transparent pixel)
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$path = $this->tempDir.'/test.png';
file_put_contents($path, $pngData);
$file = InputFile::fromPath($path);
$mimeType = $file->getMimeType();
$this->assertSame('image/png', $mimeType);
}
#[RequiresPhpExtension('fileinfo')]
public function testGuessExtension()
{
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$path = $this->tempDir.'/test.png';
file_put_contents($path, $pngData);
$file = InputFile::fromPath($path);
$this->assertSame('png', $file->guessExtension());
}
public function testMoveTemporaryFile()
{
$data = 'test content';
$file = InputFile::fromData($data);
$originalPath = $file->getPathname();
$destination = $this->tempDir.'/destination';
mkdir($destination, 0o777, true);
$movedFile = $file->move($destination, 'moved.txt');
$this->assertFileDoesNotExist($originalPath);
$this->assertFileExists($destination.'/moved.txt');
$this->assertSame('test content', $movedFile->getContents());
$this->assertFalse($movedFile->isTempFile());
}
public function testMoveNonTemporaryFileCopies()
{
$path = $this->tempDir.'/original.txt';
file_put_contents($path, 'test content');
$file = InputFile::fromPath($path);
$destination = $this->tempDir.'/destination';
mkdir($destination, 0o777, true);
$movedFile = $file->move($destination, 'copied.txt');
// Original file should still exist (copy, not move)
$this->assertFileExists($path);
$this->assertFileExists($destination.'/copied.txt');
$this->assertSame('test content', $movedFile->getContents());
}
public function testMoveCreatesDirectory()
{
$data = 'test content';
$file = InputFile::fromData($data);
$destination = $this->tempDir.'/new/nested/directory';
$movedFile = $file->move($destination, 'file.txt');
$this->assertDirectoryExists($destination);
$this->assertFileExists($destination.'/file.txt');
}
public function testMoveToNonWritableDirectory()
{
if ('\\' === \DIRECTORY_SEPARATOR) {
$this->markTestSkipped('Test not applicable on Windows.');
}
$data = 'test content';
$file = InputFile::fromData($data);
$destination = $this->tempDir.'/readonly';
mkdir($destination, 0o555, true);
$this->expectException(InvalidFileException::class);
try {
$file->move($destination, 'file.txt');
} finally {
chmod($destination, 0o777);
}
}
public function testCleanupRemovesTempFile()
{
$file = InputFile::fromData('test content');
$path = $file->getPathname();
$this->assertFileExists($path);
$file->cleanup();
$this->assertFileDoesNotExist($path);
}
public function testCleanupDoesNothingForNonTempFile()
{
$path = $this->tempDir.'/permanent.txt';
file_put_contents($path, 'test content');
$file = InputFile::fromPath($path);
$file->cleanup();
$this->assertFileExists($path);
}
public function testIsValid()
{
$path = $this->tempDir.'/valid.txt';
file_put_contents($path, 'test');
$file = InputFile::fromPath($path);
$this->assertTrue($file->isValid());
unlink($path);
$this->assertFalse($file->isValid());
}
public function testGetHumanReadableSize()
{
$file = InputFile::fromData(str_repeat('x', 1024));
$this->assertSame('1.0 KB', $file->getHumanReadableSize());
$file = InputFile::fromData(str_repeat('x', 100));
$this->assertSame('100.0 B', $file->getHumanReadableSize());
}
public function testGetContentsThrowsForInvalidFile()
{
$path = $this->tempDir.'/to_delete.txt';
file_put_contents($path, 'test');
$file = InputFile::fromPath($path);
unlink($path);
$this->expectException(InvalidFileException::class);
$this->expectExceptionMessage('Cannot read an invalid file');
$file->getContents();
}
}

View File

@@ -0,0 +1,117 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Question;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Question\FileQuestion;
class FileQuestionTest extends TestCase
{
public function testGetQuestion()
{
$question = new FileQuestion('Provide a file:');
$this->assertSame('Provide a file:', $question->getQuestion());
}
public function testDefaultOptions()
{
$question = new FileQuestion('Provide a file:');
$this->assertSame([], $question->getAllowedMimeTypes());
$this->assertSame(5 * 1024 * 1024, $question->getMaxFileSize());
$this->assertTrue($question->isPasteAllowed());
$this->assertTrue($question->isPathAllowed());
$this->assertFalse($question->isTrimmable());
}
public function testWithAllowedMimeTypes()
{
$question = new FileQuestion('Provide a file:', ['image/png', 'image/jpeg']);
$this->assertSame(['image/png', 'image/jpeg'], $question->getAllowedMimeTypes());
}
public function testWithMaxFileSize()
{
$question = new FileQuestion('Provide a file:', [], 1024 * 1024);
$this->assertSame(1024 * 1024, $question->getMaxFileSize());
}
public function testWithAllowPasteFalse()
{
$question = new FileQuestion('Provide a file:', [], null, false);
$this->assertFalse($question->isPasteAllowed());
}
public function testWithAllowPathFalse()
{
$question = new FileQuestion('Provide a file:', [], null, true, false);
$this->assertFalse($question->isPathAllowed());
}
public function testBothPasteAndPathFalseThrowsException()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('At least one of allowPaste or allowPath must be true.');
new FileQuestion('Provide a file:', [], null, false, false);
}
#[DataProvider('provideMimeTypeMatches')]
public function testIsMimeTypeAllowed(array $allowedTypes, string $mimeType, bool $expected)
{
$question = new FileQuestion('Provide a file:', $allowedTypes);
$this->assertSame($expected, $question->isMimeTypeAllowed($mimeType));
}
public static function provideMimeTypeMatches(): iterable
{
// Empty allowed types allows all
yield 'empty allows all' => [[], 'image/png', true];
yield 'empty allows all - text' => [[], 'text/plain', true];
// Exact matches
yield 'exact match png' => [['image/png'], 'image/png', true];
yield 'exact match jpeg' => [['image/jpeg'], 'image/jpeg', true];
yield 'no match' => [['image/png'], 'image/jpeg', false];
// Multiple allowed types
yield 'multiple - first match' => [['image/png', 'image/jpeg'], 'image/png', true];
yield 'multiple - second match' => [['image/png', 'image/jpeg'], 'image/jpeg', true];
yield 'multiple - no match' => [['image/png', 'image/jpeg'], 'image/gif', false];
// Wildcard matches
yield 'wildcard image/*' => [['image/*'], 'image/png', true];
yield 'wildcard image/* - jpeg' => [['image/*'], 'image/jpeg', true];
yield 'wildcard image/* - gif' => [['image/*'], 'image/gif', true];
yield 'wildcard no match' => [['image/*'], 'application/pdf', false];
// Mixed exact and wildcard
yield 'mixed - exact match' => [['application/pdf', 'image/*'], 'application/pdf', true];
yield 'mixed - wildcard match' => [['application/pdf', 'image/*'], 'image/png', true];
yield 'mixed - no match' => [['application/pdf', 'image/*'], 'text/plain', false];
}
public function testIsNotTrimmableByDefault()
{
$question = new FileQuestion('Provide a file:');
$this->assertFalse($question->isTrimmable());
}
}

View File

@@ -0,0 +1,171 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Terminal\Image;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Terminal\Image\ITerm2Protocol;
class ITerm2ProtocolTest extends TestCase
{
public function testGetName()
{
$this->assertSame('iterm2', (new ITerm2Protocol())->getName());
}
public function testDetectPastedImageWithITerm2Sequence()
{
$data = "some text\x1b]1337;File=inline=1:base64data\x07more text";
$this->assertTrue((new ITerm2Protocol())->detectPastedImage($data));
}
public function testDetectPastedImageWithoutITerm2Sequence()
{
$data = 'just plain text';
$this->assertFalse((new ITerm2Protocol())->detectPastedImage($data));
}
public function testDecodeValidPayload()
{
$imageData = 'test image data';
$base64 = base64_encode($imageData);
$data = "\x1b]1337;File=inline=1:{$base64}\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame($imageData, $result['data']);
}
public function testDecodeWithStTerminator()
{
$imageData = 'test image data';
$base64 = base64_encode($imageData);
$data = "\x1b]1337;File=inline=1:{$base64}\x1b\\";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame($imageData, $result['data']);
}
public function testDecodeInvalidBase64()
{
$data = "\x1b]1337;File=inline=1:not-valid-base64!!!\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('', $result['data']);
$this->assertNull($result['format']);
}
public function testDecodeWithNoPayload()
{
$data = 'just text';
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('', $result['data']);
$this->assertNull($result['format']);
}
public function testDecodeWithNoTerminator()
{
$data = "\x1b]1337;File=inline=1:".base64_encode('test');
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('', $result['data']);
}
public function testDecodeWithNoColon()
{
$data = "\x1b]1337;File=inline=1\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('', $result['data']);
}
public function testEncode()
{
$imageData = 'test image data';
$encoded = (new ITerm2Protocol())->encode($imageData);
$this->assertStringStartsWith("\x1b]1337;File=", $encoded);
$this->assertStringEndsWith("\x07", $encoded);
$this->assertStringContainsString('inline=1', $encoded);
$this->assertStringContainsString(base64_encode($imageData), $encoded);
}
public function testEncodeWithMaxWidth()
{
$imageData = 'test image data';
$encoded = (new ITerm2Protocol())->encode($imageData, 50);
$this->assertStringContainsString('width=50', $encoded);
}
public function testEncodePreservesAspectRatio()
{
$imageData = 'test image data';
$encoded = (new ITerm2Protocol())->encode($imageData);
$this->assertStringContainsString('preserveAspectRatio=1', $encoded);
}
public function testDecodeDetectsPngFormat()
{
$pngData = "\x89PNG\r\n\x1a\n".str_repeat("\x00", 10);
$base64 = base64_encode($pngData);
$data = "\x1b]1337;File=inline=1:{$base64}\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('png', $result['format']);
}
public function testDecodeDetectsJpegFormat()
{
$jpegData = "\xFF\xD8\xFF".str_repeat("\x00", 10);
$base64 = base64_encode($jpegData);
$data = "\x1b]1337;File=inline=1:{$base64}\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('jpg', $result['format']);
}
public function testDecodeDetectsGifFormat()
{
$gifData = 'GIF89a'.str_repeat("\x00", 10);
$base64 = base64_encode($gifData);
$data = "\x1b]1337;File=inline=1:{$base64}\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('gif', $result['format']);
}
public function testDecodeDetectsWebpFormat()
{
$webpData = "RIFF\x00\x00\x00\x00WEBP".str_repeat("\x00", 10);
$base64 = base64_encode($webpData);
$data = "\x1b]1337;File=inline=1:{$base64}\x07";
$result = (new ITerm2Protocol())->decode($data);
$this->assertSame('webp', $result['format']);
}
}

View File

@@ -0,0 +1,137 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Terminal\Image;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Terminal\Image\KittyGraphicsProtocol;
class KittyGraphicsProtocolTest extends TestCase
{
public function testGetName()
{
$this->assertSame('kitty', (new KittyGraphicsProtocol())->getName());
}
public function testDetectPastedImageWithKittySequence()
{
$data = "some text\x1b_Ga=T,f=100;base64data\x1b\\more text";
$this->assertTrue((new KittyGraphicsProtocol())->detectPastedImage($data));
}
public function testDetectPastedImageWithoutKittySequence()
{
$data = 'just plain text';
$this->assertFalse((new KittyGraphicsProtocol())->detectPastedImage($data));
}
public function testDecodeValidPayload()
{
$imageData = 'test image data';
$base64 = base64_encode($imageData);
$data = "\x1b_Ga=T,f=100;{$base64}\x1b\\";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame($imageData, $result['data']);
$this->assertSame('png', $result['format']);
}
public function testDecodeWithBellTerminator()
{
$imageData = 'test image data';
$base64 = base64_encode($imageData);
$data = "\x1b_Ga=T,f=100;{$base64}\x07";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame($imageData, $result['data']);
}
public function testDecodeInvalidBase64()
{
$data = "\x1b_Ga=T,f=100;not-valid-base64!!!\x1b\\";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('', $result['data']);
$this->assertNull($result['format']);
}
public function testDecodeWithNoPayload()
{
$data = 'just text';
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('', $result['data']);
$this->assertNull($result['format']);
}
public function testDecodeWithNoTerminator()
{
$data = "\x1b_Ga=T,f=100;".base64_encode('test');
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('', $result['data']);
}
public function testDecodeWithNoSemicolon()
{
$data = "\x1b_Ga=T,f=100\x1b\\";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('', $result['data']);
}
public function testEncode()
{
$imageData = 'test image data';
$encoded = (new KittyGraphicsProtocol())->encode($imageData);
$this->assertStringStartsWith("\x1b_G", $encoded);
$this->assertStringEndsWith("\x1b\\", $encoded);
$this->assertStringContainsString(base64_encode($imageData), $encoded);
}
public function testEncodeWithMaxWidth()
{
$imageData = 'test image data';
$encoded = (new KittyGraphicsProtocol())->encode($imageData, 50);
$this->assertStringContainsString('c=50', $encoded);
}
public function testEncodePngFormat()
{
$pngData = "\x89PNG\r\n\x1a\n".str_repeat("\x00", 10);
$encoded = (new KittyGraphicsProtocol())->encode($pngData);
$this->assertStringContainsString('f=100', $encoded);
}
public function testDecodeDifferentFormats()
{
$data = "\x1b_Gf=24;".base64_encode('rgb data')."\x1b\\";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('rgb', $result['format']);
$data = "\x1b_Gf=32;".base64_encode('rgba data')."\x1b\\";
$result = (new KittyGraphicsProtocol())->decode($data);
$this->assertSame('rgba', $result['format']);
}
}

View File

@@ -40,7 +40,7 @@ class TerminalTest extends TestCase
private function resetStatics()
{
foreach (['height', 'width', 'stty'] as $name) {
foreach (['height', 'width', 'stty', 'kittyGraphics', 'iterm2Images'] as $name) {
$property = new \ReflectionProperty(Terminal::class, $name);
$property->setValue(null, null);
}
@@ -147,4 +147,114 @@ class TerminalTest extends TestCase
Terminal::setColorMode(null);
}
}
#[DataProvider('provideKittyGraphicsEnv')]
public function testSupportsKittyGraphics(?string $termProgram, ?string $term, ?string $ghosttyResources, ?string $konsoleVersion, bool $expected)
{
$oriTermProgram = getenv('TERM_PROGRAM');
$oriTerm = getenv('TERM');
$oriGhostty = getenv('GHOSTTY_RESOURCES_DIR');
$oriKonsole = getenv('KONSOLE_VERSION');
try {
putenv($termProgram ? "TERM_PROGRAM={$termProgram}" : 'TERM_PROGRAM');
putenv($term ? "TERM={$term}" : 'TERM');
putenv($ghosttyResources ? "GHOSTTY_RESOURCES_DIR={$ghosttyResources}" : 'GHOSTTY_RESOURCES_DIR');
putenv($konsoleVersion ? "KONSOLE_VERSION={$konsoleVersion}" : 'KONSOLE_VERSION');
$this->assertSame($expected, Terminal::supportsKittyGraphics());
} finally {
(false !== $oriTermProgram) ? putenv('TERM_PROGRAM='.$oriTermProgram) : putenv('TERM_PROGRAM');
(false !== $oriTerm) ? putenv('TERM='.$oriTerm) : putenv('TERM');
(false !== $oriGhostty) ? putenv('GHOSTTY_RESOURCES_DIR='.$oriGhostty) : putenv('GHOSTTY_RESOURCES_DIR');
(false !== $oriKonsole) ? putenv('KONSOLE_VERSION='.$oriKonsole) : putenv('KONSOLE_VERSION');
Terminal::setKittyGraphicsSupport(null);
}
}
public static function provideKittyGraphicsEnv(): \Generator
{
// TERM_PROGRAM checks
yield 'kitty terminal' => ['kitty', null, null, null, true];
yield 'WezTerm terminal' => ['WezTerm', null, null, null, true];
yield 'ghostty terminal' => ['ghostty', null, null, null, true];
yield 'other terminal program' => ['iTerm.app', null, null, null, false];
// TERM checks
yield 'kitty in TERM' => [null, 'xterm-kitty', null, null, true];
yield 'other TERM' => [null, 'xterm-256color', null, null, false];
// GHOSTTY_RESOURCES_DIR check
yield 'ghostty resources' => [null, null, '/some/path', null, true];
// KONSOLE_VERSION check
yield 'konsole' => [null, null, null, '22.12.3', true];
// None
yield 'no support' => [null, null, null, null, false];
}
#[DataProvider('provideITerm2ImagesEnv')]
public function testSupportsITerm2Images(?string $termProgram, bool $expected)
{
$oriTermProgram = getenv('TERM_PROGRAM');
try {
putenv($termProgram ? "TERM_PROGRAM={$termProgram}" : 'TERM_PROGRAM');
$this->assertSame($expected, Terminal::supportsITerm2Images());
} finally {
(false !== $oriTermProgram) ? putenv('TERM_PROGRAM='.$oriTermProgram) : putenv('TERM_PROGRAM');
Terminal::setITerm2ImagesSupport(null);
}
}
public static function provideITerm2ImagesEnv(): \Generator
{
yield 'iTerm.app' => ['iTerm.app', true];
yield 'other terminal' => ['Terminal.app', false];
yield 'kitty' => ['kitty', false];
yield 'no terminal program' => [null, false];
}
public function testSupportsImageProtocol()
{
Terminal::setKittyGraphicsSupport(false);
Terminal::setITerm2ImagesSupport(false);
$this->assertFalse(Terminal::supportsImageProtocol());
Terminal::setKittyGraphicsSupport(true);
Terminal::setITerm2ImagesSupport(false);
$this->assertTrue(Terminal::supportsImageProtocol());
Terminal::setKittyGraphicsSupport(false);
Terminal::setITerm2ImagesSupport(true);
$this->assertTrue(Terminal::supportsImageProtocol());
Terminal::setKittyGraphicsSupport(true);
Terminal::setITerm2ImagesSupport(true);
$this->assertTrue(Terminal::supportsImageProtocol());
}
public function testSetKittyGraphicsSupport()
{
Terminal::setKittyGraphicsSupport(true);
$this->assertTrue(Terminal::supportsKittyGraphics());
Terminal::setKittyGraphicsSupport(false);
$this->assertFalse(Terminal::supportsKittyGraphics());
Terminal::setKittyGraphicsSupport(null);
}
public function testSetITerm2ImagesSupport()
{
Terminal::setITerm2ImagesSupport(true);
$this->assertTrue(Terminal::supportsITerm2Images());
Terminal::setITerm2ImagesSupport(false);
$this->assertFalse(Terminal::supportsITerm2Images());
Terminal::setITerm2ImagesSupport(null);
}
}

View File

@@ -26,6 +26,7 @@
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/filesystem": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/lock": "^7.4|^8.0",