mirror of
https://github.com/symfony/console.git
synced 2026-03-24 01:12:13 +01:00
[Console] Add image support to QuestionHelper and #[Ask]
This commit is contained in:
@@ -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,
|
||||
|
||||
60
ArgumentResolver/ValueResolver/InputFileValueResolver.php
Normal file
60
ArgumentResolver/ValueResolver/InputFileValueResolver.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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
|
||||
|
||||
19
Exception/InvalidFileException.php
Normal file
19
Exception/InvalidFileException.php
Normal 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
244
Helper/FileInputHelper.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
256
Input/File/InputFile.php
Normal 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
94
Question/FileQuestion.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
64
Terminal.php
64
Terminal.php
@@ -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']);
|
||||
|
||||
103
Terminal/Image/ITerm2Protocol.php
Normal file
103
Terminal/Image/ITerm2Protocol.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
33
Terminal/Image/ImageProtocolInterface.php
Normal file
33
Terminal/Image/ImageProtocolInterface.php
Normal 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;
|
||||
}
|
||||
136
Terminal/Image/KittyGraphicsProtocol.php
Normal file
136
Terminal/Image/KittyGraphicsProtocol.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
BIN
Tests/Fixtures/File/test.png
Normal file
BIN
Tests/Fixtures/File/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
35
Tests/Fixtures/InvokableWithInputFileTestCommand.php
Normal file
35
Tests/Fixtures/InvokableWithInputFileTestCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
305
Tests/Input/File/InputFileTest.php
Normal file
305
Tests/Input/File/InputFileTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
117
Tests/Question/FileQuestionTest.php
Normal file
117
Tests/Question/FileQuestionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
171
Tests/Terminal/Image/ITerm2ProtocolTest.php
Normal file
171
Tests/Terminal/Image/ITerm2ProtocolTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
137
Tests/Terminal/Image/KittyGraphicsProtocolTest.php
Normal file
137
Tests/Terminal/Image/KittyGraphicsProtocolTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user