Initial commit
This commit is contained in:
+697
@@ -0,0 +1,697 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
|
||||
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\BrowserKit\Exception\LogicException;
|
||||
use Symfony\Component\BrowserKit\Exception\RuntimeException;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\DomCrawler\Form;
|
||||
use Symfony\Component\DomCrawler\Link;
|
||||
use Symfony\Component\Process\PhpProcess;
|
||||
|
||||
/**
|
||||
* Simulates a browser.
|
||||
*
|
||||
* To make the actual request, you need to implement the doRequest() method.
|
||||
*
|
||||
* If you want to be able to run requests in their own process (insulated flag),
|
||||
* you need to also implement the getScript() method.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @template TRequest of object
|
||||
* @template TResponse of object
|
||||
*/
|
||||
abstract class AbstractBrowser
|
||||
{
|
||||
protected History $history;
|
||||
protected CookieJar $cookieJar;
|
||||
protected array $server = [];
|
||||
protected Request $internalRequest;
|
||||
/** @psalm-var TRequest */
|
||||
protected object $request;
|
||||
protected Response $internalResponse;
|
||||
/** @psalm-var TResponse */
|
||||
protected object $response;
|
||||
protected Crawler $crawler;
|
||||
protected bool $useHtml5Parser = true;
|
||||
protected bool $insulated = false;
|
||||
protected ?string $redirect;
|
||||
protected bool $followRedirects = true;
|
||||
protected bool $followMetaRefresh = false;
|
||||
|
||||
private int $maxRedirects = -1;
|
||||
private int $redirectCount = 0;
|
||||
private array $redirects = [];
|
||||
private bool $isMainRequest = true;
|
||||
|
||||
/**
|
||||
* @param array $server The server parameters (equivalent of $_SERVER)
|
||||
*/
|
||||
public function __construct(array $server = [], ?History $history = null, ?CookieJar $cookieJar = null)
|
||||
{
|
||||
$this->setServerParameters($server);
|
||||
$this->history = $history ?? new History();
|
||||
$this->cookieJar = $cookieJar ?? new CookieJar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to automatically follow redirects or not.
|
||||
*/
|
||||
public function followRedirects(bool $followRedirects = true): void
|
||||
{
|
||||
$this->followRedirects = $followRedirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to automatically follow meta refresh redirects or not.
|
||||
*/
|
||||
public function followMetaRefresh(bool $followMetaRefresh = true): void
|
||||
{
|
||||
$this->followMetaRefresh = $followMetaRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether client automatically follows redirects or not.
|
||||
*/
|
||||
public function isFollowingRedirects(): bool
|
||||
{
|
||||
return $this->followRedirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of redirects that crawler can follow.
|
||||
*/
|
||||
public function setMaxRedirects(int $maxRedirects): void
|
||||
{
|
||||
$this->maxRedirects = $maxRedirects < 0 ? -1 : $maxRedirects;
|
||||
$this->followRedirects = -1 !== $this->maxRedirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of redirects that crawler can follow.
|
||||
*/
|
||||
public function getMaxRedirects(): int
|
||||
{
|
||||
return $this->maxRedirects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the insulated flag.
|
||||
*
|
||||
* @throws LogicException When Symfony Process Component is not installed
|
||||
*/
|
||||
public function insulate(bool $insulated = true): void
|
||||
{
|
||||
if ($insulated && !class_exists(\Symfony\Component\Process\Process::class)) {
|
||||
throw new LogicException('Unable to isolate requests as the Symfony Process Component is not installed. Try running "composer require symfony/process".');
|
||||
}
|
||||
|
||||
$this->insulated = $insulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets server parameters.
|
||||
*/
|
||||
public function setServerParameters(array $server): void
|
||||
{
|
||||
$this->server = array_merge([
|
||||
'HTTP_USER_AGENT' => 'Symfony BrowserKit',
|
||||
], $server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets single server parameter.
|
||||
*/
|
||||
public function setServerParameter(string $key, string $value): void
|
||||
{
|
||||
$this->server[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets single server parameter for specified key.
|
||||
*/
|
||||
public function getServerParameter(string $key, mixed $default = ''): mixed
|
||||
{
|
||||
return $this->server[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function xmlHttpRequest(string $method, string $uri, array $parameters = [], array $files = [], array $server = [], ?string $content = null, bool $changeHistory = true): Crawler
|
||||
{
|
||||
$this->setServerParameter('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest');
|
||||
|
||||
try {
|
||||
return $this->request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
|
||||
} finally {
|
||||
unset($this->server['HTTP_X_REQUESTED_WITH']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the request parameters into a JSON string and uses it as request content.
|
||||
*/
|
||||
public function jsonRequest(string $method, string $uri, array $parameters = [], array $server = [], bool $changeHistory = true): Crawler
|
||||
{
|
||||
$content = json_encode($parameters, \JSON_PRESERVE_ZERO_FRACTION);
|
||||
|
||||
$this->setServerParameter('CONTENT_TYPE', 'application/json');
|
||||
$this->setServerParameter('HTTP_ACCEPT', 'application/json');
|
||||
|
||||
try {
|
||||
return $this->request($method, $uri, [], [], $server, $content, $changeHistory);
|
||||
} finally {
|
||||
unset($this->server['CONTENT_TYPE']);
|
||||
unset($this->server['HTTP_ACCEPT']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the History instance.
|
||||
*/
|
||||
public function getHistory(): History
|
||||
{
|
||||
return $this->history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CookieJar instance.
|
||||
*/
|
||||
public function getCookieJar(): CookieJar
|
||||
{
|
||||
return $this->cookieJar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current Crawler instance.
|
||||
*/
|
||||
public function getCrawler(): Crawler
|
||||
{
|
||||
return $this->crawler ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether parsing should be done using "masterminds/html5".
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function useHtml5Parser(bool $useHtml5Parser): static
|
||||
{
|
||||
$this->useHtml5Parser = $useHtml5Parser;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current BrowserKit Response instance.
|
||||
*/
|
||||
public function getInternalResponse(): Response
|
||||
{
|
||||
return $this->internalResponse ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current origin response instance.
|
||||
*
|
||||
* The origin response is the response instance that is returned
|
||||
* by the code that handles requests.
|
||||
*
|
||||
* @psalm-return TResponse
|
||||
*
|
||||
* @see doRequest()
|
||||
*/
|
||||
public function getResponse(): object
|
||||
{
|
||||
return $this->response ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current BrowserKit Request instance.
|
||||
*/
|
||||
public function getInternalRequest(): Request
|
||||
{
|
||||
return $this->internalRequest ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current origin Request instance.
|
||||
*
|
||||
* The origin request is the request instance that is sent
|
||||
* to the code that handles requests.
|
||||
*
|
||||
* @psalm-return TRequest
|
||||
*
|
||||
* @see doRequest()
|
||||
*/
|
||||
public function getRequest(): object
|
||||
{
|
||||
return $this->request ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on a given link.
|
||||
*
|
||||
* @param array $serverParameters An array of server parameters
|
||||
*/
|
||||
public function click(Link $link, array $serverParameters = []): Crawler
|
||||
{
|
||||
if ($link instanceof Form) {
|
||||
return $this->submit($link, [], $serverParameters);
|
||||
}
|
||||
|
||||
return $this->request($link->getMethod(), $link->getUri(), [], [], $serverParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the first link (or clickable image) that contains the given text.
|
||||
*
|
||||
* @param string $linkText The text of the link or the alt attribute of the clickable image
|
||||
* @param array $serverParameters An array of server parameters
|
||||
*/
|
||||
public function clickLink(string $linkText, array $serverParameters = []): Crawler
|
||||
{
|
||||
$crawler = $this->crawler ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
|
||||
return $this->click($crawler->selectLink($linkText)->link(), $serverParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a form.
|
||||
*
|
||||
* @param array $values An array of form field values
|
||||
* @param array $serverParameters An array of server parameters
|
||||
*/
|
||||
public function submit(Form $form, array $values = [], array $serverParameters = []): Crawler
|
||||
{
|
||||
$form->setValues($values);
|
||||
|
||||
return $this->request($form->getMethod(), $form->getUri(), $form->getPhpValues(), $form->getPhpFiles(), $serverParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first form that contains a button with the given content and
|
||||
* uses it to submit the given form field values.
|
||||
*
|
||||
* @param string $button The text content, id, value or name of the form <button> or <input type="submit">
|
||||
* @param array $fieldValues Use this syntax: ['my_form[name]' => '...', 'my_form[email]' => '...']
|
||||
* @param string $method The HTTP method used to submit the form
|
||||
* @param array $serverParameters These values override the ones stored in $_SERVER (HTTP headers must include an HTTP_ prefix as PHP does)
|
||||
*/
|
||||
public function submitForm(string $button, array $fieldValues = [], string $method = 'POST', array $serverParameters = []): Crawler
|
||||
{
|
||||
$crawler = $this->crawler ?? throw new BadMethodCallException(\sprintf('The "request()" method must be called before "%s()".', __METHOD__));
|
||||
$buttonNode = $crawler->selectButton($button);
|
||||
|
||||
if (0 === $buttonNode->count()) {
|
||||
throw new InvalidArgumentException(\sprintf('There is no button with "%s" as its content, id, value or name.', $button));
|
||||
}
|
||||
|
||||
$form = $buttonNode->form($fieldValues, $method);
|
||||
|
||||
return $this->submit($form, [], $serverParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a URI.
|
||||
*
|
||||
* @param string $method The request method
|
||||
* @param string $uri The URI to fetch
|
||||
* @param array $parameters The Request parameters
|
||||
* @param array $files The files
|
||||
* @param array $server The server parameters (HTTP headers are referenced with an HTTP_ prefix as PHP does)
|
||||
* @param string $content The raw body data
|
||||
* @param bool $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
|
||||
*/
|
||||
public function request(string $method, string $uri, array $parameters = [], array $files = [], array $server = [], ?string $content = null, bool $changeHistory = true): Crawler
|
||||
{
|
||||
if ($this->isMainRequest) {
|
||||
$this->redirectCount = 0;
|
||||
} else {
|
||||
++$this->redirectCount;
|
||||
}
|
||||
|
||||
$originalUri = $uri;
|
||||
|
||||
$uri = $this->getAbsoluteUri($uri);
|
||||
|
||||
$server = array_merge($this->server, $server);
|
||||
|
||||
if (!empty($server['HTTP_HOST']) && !parse_url($originalUri, \PHP_URL_HOST)) {
|
||||
$uri = preg_replace('{^(https?\://)'.preg_quote($this->extractHost($uri)).'}', '${1}'.$server['HTTP_HOST'], $uri);
|
||||
}
|
||||
|
||||
if (isset($server['HTTPS']) && !parse_url($originalUri, \PHP_URL_SCHEME)) {
|
||||
$uri = preg_replace('{^'.parse_url($uri, \PHP_URL_SCHEME).'}', $server['HTTPS'] ? 'https' : 'http', $uri);
|
||||
}
|
||||
|
||||
if (!isset($server['HTTP_REFERER']) && !$this->history->isEmpty()) {
|
||||
$server['HTTP_REFERER'] = $this->history->current()->getUri();
|
||||
}
|
||||
|
||||
if (empty($server['HTTP_HOST'])) {
|
||||
$server['HTTP_HOST'] = $this->extractHost($uri);
|
||||
}
|
||||
|
||||
$server['HTTPS'] = 'https' === parse_url($uri, \PHP_URL_SCHEME);
|
||||
|
||||
$this->internalRequest = new Request($uri, $method, $parameters, $files, $this->cookieJar->allValues($uri), $server, $content);
|
||||
|
||||
$this->request = $this->filterRequest($this->internalRequest);
|
||||
|
||||
if (true === $changeHistory) {
|
||||
$this->history->add($this->internalRequest);
|
||||
}
|
||||
|
||||
if ($this->insulated) {
|
||||
$this->response = $this->doRequestInProcess($this->request);
|
||||
} else {
|
||||
$this->response = $this->doRequest($this->request);
|
||||
}
|
||||
|
||||
$this->internalResponse = $this->filterResponse($this->response);
|
||||
|
||||
$this->cookieJar->updateFromResponse($this->internalResponse, $uri);
|
||||
|
||||
$status = $this->internalResponse->getStatusCode();
|
||||
|
||||
if ($status >= 300 && $status < 400) {
|
||||
$this->redirect = $this->internalResponse->getHeader('Location');
|
||||
} else {
|
||||
$this->redirect = null;
|
||||
}
|
||||
|
||||
if ($this->followRedirects && $this->redirect) {
|
||||
$this->redirects[serialize($this->history->current())] = true;
|
||||
|
||||
return $this->crawler = $this->followRedirect();
|
||||
}
|
||||
|
||||
$this->crawler = $this->createCrawlerFromContent($this->internalRequest->getUri(), $this->internalResponse->getContent(), $this->internalResponse->getHeader('Content-Type') ?? '');
|
||||
|
||||
// Check for meta refresh redirect
|
||||
if ($this->followMetaRefresh && null !== $redirect = $this->getMetaRefreshUrl()) {
|
||||
$this->redirect = $redirect;
|
||||
$this->redirects[serialize($this->history->current())] = true;
|
||||
$this->crawler = $this->followRedirect();
|
||||
}
|
||||
|
||||
return $this->crawler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request in another process.
|
||||
*
|
||||
* @psalm-param TRequest $request
|
||||
*
|
||||
* @return object
|
||||
*
|
||||
* @psalm-return TResponse
|
||||
*
|
||||
* @throws \RuntimeException When processing returns exit code
|
||||
*/
|
||||
protected function doRequestInProcess(object $request)
|
||||
{
|
||||
$deprecationsFile = tempnam(sys_get_temp_dir(), 'deprec');
|
||||
putenv('SYMFONY_DEPRECATIONS_SERIALIZE='.$deprecationsFile);
|
||||
$_ENV['SYMFONY_DEPRECATIONS_SERIALIZE'] = $deprecationsFile;
|
||||
$process = new PhpProcess($this->getScript($request), null, null);
|
||||
$process->run();
|
||||
|
||||
if (file_exists($deprecationsFile)) {
|
||||
$deprecations = file_get_contents($deprecationsFile);
|
||||
unlink($deprecationsFile);
|
||||
foreach ($deprecations ? unserialize($deprecations) : [] as $deprecation) {
|
||||
if ($deprecation[0]) {
|
||||
// unsilenced on purpose
|
||||
trigger_error($deprecation[1], \E_USER_DEPRECATED);
|
||||
} else {
|
||||
@trigger_error($deprecation[1], \E_USER_DEPRECATED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful() || !preg_match('/^O\:\d+\:/', $process->getOutput())) {
|
||||
throw new RuntimeException(\sprintf('OUTPUT: %s ERROR OUTPUT: %s.', $process->getOutput(), $process->getErrorOutput()));
|
||||
}
|
||||
|
||||
return unserialize($process->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request.
|
||||
*
|
||||
* @psalm-param TRequest $request
|
||||
*
|
||||
* @return object
|
||||
*
|
||||
* @psalm-return TResponse
|
||||
*/
|
||||
abstract protected function doRequest(object $request);
|
||||
|
||||
/**
|
||||
* Returns the script to execute when the request must be insulated.
|
||||
*
|
||||
* @param object $request An origin request instance
|
||||
*
|
||||
* @psalm-param TRequest $request
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws LogicException When this abstract class is not implemented
|
||||
*/
|
||||
protected function getScript(object $request)
|
||||
{
|
||||
throw new LogicException('To insulate requests, you need to override the getScript() method.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the BrowserKit request to the origin one.
|
||||
*
|
||||
* @return object
|
||||
*
|
||||
* @psalm-return TRequest
|
||||
*/
|
||||
protected function filterRequest(Request $request)
|
||||
{
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the origin response to the BrowserKit one.
|
||||
*
|
||||
* @psalm-param TResponse $response
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
protected function filterResponse(object $response)
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a crawler.
|
||||
*
|
||||
* This method returns null if the DomCrawler component is not available.
|
||||
*/
|
||||
protected function createCrawlerFromContent(string $uri, string $content, string $type): ?Crawler
|
||||
{
|
||||
if (!class_exists(Crawler::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$crawler = new Crawler(null, $uri, null, $this->useHtml5Parser);
|
||||
$crawler->addContent($content, $type);
|
||||
|
||||
return $crawler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes back in the browser history.
|
||||
*/
|
||||
public function back(): Crawler
|
||||
{
|
||||
do {
|
||||
$request = $this->history->back();
|
||||
} while (\array_key_exists(serialize($request), $this->redirects));
|
||||
|
||||
return $this->requestFromRequest($request, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes forward in the browser history.
|
||||
*/
|
||||
public function forward(): Crawler
|
||||
{
|
||||
do {
|
||||
$request = $this->history->forward();
|
||||
} while (\array_key_exists(serialize($request), $this->redirects));
|
||||
|
||||
return $this->requestFromRequest($request, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current browser.
|
||||
*/
|
||||
public function reload(): Crawler
|
||||
{
|
||||
return $this->requestFromRequest($this->history->current(), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow redirects?
|
||||
*
|
||||
* @throws LogicException If request was not a redirect
|
||||
*/
|
||||
public function followRedirect(): Crawler
|
||||
{
|
||||
if (!isset($this->redirect)) {
|
||||
throw new LogicException('The request was not redirected.');
|
||||
}
|
||||
|
||||
if (-1 !== $this->maxRedirects) {
|
||||
if ($this->redirectCount > $this->maxRedirects) {
|
||||
$this->redirectCount = 0;
|
||||
throw new LogicException(\sprintf('The maximum number (%d) of redirections was reached.', $this->maxRedirects));
|
||||
}
|
||||
}
|
||||
|
||||
$request = $this->internalRequest;
|
||||
|
||||
if (\in_array($this->internalResponse->getStatusCode(), [301, 302, 303])) {
|
||||
$method = 'GET';
|
||||
$files = [];
|
||||
$content = null;
|
||||
} else {
|
||||
$method = $request->getMethod();
|
||||
$files = $request->getFiles();
|
||||
$content = $request->getContent();
|
||||
}
|
||||
|
||||
if ('GET' === strtoupper($method)) {
|
||||
// Don't forward parameters for GET request as it should reach the redirection URI
|
||||
$parameters = [];
|
||||
} else {
|
||||
$parameters = $request->getParameters();
|
||||
}
|
||||
|
||||
$server = $request->getServer();
|
||||
$server = $this->updateServerFromUri($server, $this->redirect);
|
||||
|
||||
$this->isMainRequest = false;
|
||||
|
||||
$response = $this->request($method, $this->redirect, $parameters, $files, $server, $content);
|
||||
|
||||
$this->isMainRequest = true;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://dev.w3.org/html5/spec-preview/the-meta-element.html#attr-meta-http-equiv-refresh
|
||||
*/
|
||||
private function getMetaRefreshUrl(): ?string
|
||||
{
|
||||
$metaRefresh = $this->getCrawler()->filter('head meta[http-equiv="refresh"]');
|
||||
foreach ($metaRefresh->extract(['content']) as $content) {
|
||||
if (preg_match('/^\s*0\s*;\s*URL\s*=\s*(?|\'([^\']++)|"([^"]++)|([^\'"].*))/i', $content, $m)) {
|
||||
return str_replace("\t\r\n", '', rtrim($m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the client.
|
||||
*
|
||||
* It flushes history and all cookies.
|
||||
*/
|
||||
public function restart(): void
|
||||
{
|
||||
$this->cookieJar->clear();
|
||||
$this->history->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URI and converts it to absolute if it is not already absolute.
|
||||
*/
|
||||
protected function getAbsoluteUri(string $uri): string
|
||||
{
|
||||
// already absolute?
|
||||
if (str_starts_with($uri, 'http://') || str_starts_with($uri, 'https://')) {
|
||||
return $uri;
|
||||
}
|
||||
|
||||
if (!$this->history->isEmpty()) {
|
||||
$currentUri = $this->history->current()->getUri();
|
||||
} else {
|
||||
$currentUri = \sprintf('http%s://%s/',
|
||||
isset($this->server['HTTPS']) ? 's' : '',
|
||||
$this->server['HTTP_HOST'] ?? 'localhost'
|
||||
);
|
||||
}
|
||||
|
||||
// protocol relative URL
|
||||
if ('' !== trim($uri, '/') && str_starts_with($uri, '//')) {
|
||||
return parse_url($currentUri, \PHP_URL_SCHEME).':'.$uri;
|
||||
}
|
||||
|
||||
// anchor or query string parameters?
|
||||
if (!$uri || '#' === $uri[0] || '?' === $uri[0]) {
|
||||
return preg_replace('/[#?].*?$/', '', $currentUri).$uri;
|
||||
}
|
||||
|
||||
if ('/' !== $uri[0]) {
|
||||
$path = parse_url($currentUri, \PHP_URL_PATH);
|
||||
|
||||
if (!str_ends_with($path, '/')) {
|
||||
$path = substr($path, 0, strrpos($path, '/') + 1);
|
||||
}
|
||||
|
||||
$uri = $path.$uri;
|
||||
}
|
||||
|
||||
return preg_replace('#^(.*?//[^/]+)\/.*$#', '$1', $currentUri).$uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request from a Request object directly.
|
||||
*
|
||||
* @param bool $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
|
||||
*/
|
||||
protected function requestFromRequest(Request $request, bool $changeHistory = true): Crawler
|
||||
{
|
||||
return $this->request($request->getMethod(), $request->getUri(), $request->getParameters(), $request->getFiles(), $request->getServer(), $request->getContent(), $changeHistory);
|
||||
}
|
||||
|
||||
private function updateServerFromUri(array $server, string $uri): array
|
||||
{
|
||||
$server['HTTP_HOST'] = $this->extractHost($uri);
|
||||
$scheme = parse_url($uri, \PHP_URL_SCHEME);
|
||||
$server['HTTPS'] = null === $scheme ? $server['HTTPS'] : 'https' === $scheme;
|
||||
unset($server['HTTP_IF_NONE_MATCH'], $server['HTTP_IF_MODIFIED_SINCE']);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function extractHost(string $uri): ?string
|
||||
{
|
||||
$host = parse_url($uri, \PHP_URL_HOST);
|
||||
|
||||
if ($port = parse_url($uri, \PHP_URL_PORT)) {
|
||||
return $host.':'.$port;
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
6.4
|
||||
---
|
||||
|
||||
* Add argument `$serverParameters` to `AbstractBrowser::click()` and `AbstractBrowser::clickLink()`
|
||||
|
||||
6.3
|
||||
---
|
||||
|
||||
* Add `AbstractBrowser::useHtml5Parser()`
|
||||
|
||||
6.1
|
||||
---
|
||||
|
||||
* Add `toArray` method to `Response`
|
||||
|
||||
5.3
|
||||
---
|
||||
|
||||
* Added `jsonRequest` method to `AbstractBrowser`
|
||||
* Allowed sending a body with GET requests when a content-type is defined
|
||||
|
||||
5.2.0
|
||||
-----
|
||||
|
||||
* [BC BREAK] Request parameters are now casted to string in `Request::__construct()`.
|
||||
|
||||
4.3.0
|
||||
-----
|
||||
|
||||
* Added PHPUnit constraints: `BrowserCookieValueSame` and `BrowserHasCookie`
|
||||
* Added `HttpBrowser`, an implementation of a browser with the HttpClient component
|
||||
* Renamed `Client` to `AbstractBrowser`
|
||||
* Marked `Response` final.
|
||||
* Deprecated `Response::buildHeader()`
|
||||
* Deprecated `Response::getStatus()`, use `Response::getStatusCode()` instead
|
||||
|
||||
4.2.0
|
||||
-----
|
||||
|
||||
* The method `Client::submit()` will have a new `$serverParameters` argument
|
||||
in version 5.0, not defining it is deprecated
|
||||
* Added ability to read the "samesite" attribute of cookies using `Cookie::getSameSite()`
|
||||
|
||||
3.4.0
|
||||
-----
|
||||
|
||||
* [BC BREAK] Client will skip redirects during history navigation
|
||||
(back and forward calls) according to W3C Browsers recommendation
|
||||
|
||||
3.3.0
|
||||
-----
|
||||
|
||||
* [BC BREAK] The request method is dropped from POST to GET when the response
|
||||
status code is 301.
|
||||
|
||||
3.2.0
|
||||
-----
|
||||
|
||||
* Client HTTP user agent has been changed to 'Symfony BrowserKit'
|
||||
|
||||
2.3.0
|
||||
-----
|
||||
|
||||
* [BC BREAK] `Client::followRedirect()` won't redirect responses with
|
||||
a non-3xx Status Code and `Location` header anymore, as per
|
||||
http://tools.ietf.org/html/rfc2616#section-14.30
|
||||
|
||||
* added `Client::getInternalRequest()` and `Client::getInternalResponse()` to
|
||||
have access to the BrowserKit internal request and response objects
|
||||
|
||||
2.1.0
|
||||
-----
|
||||
|
||||
* [BC BREAK] The CookieJar internals have changed to allow cookies with the
|
||||
same name on different sub-domains/sub-paths
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\BrowserKit\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Cookie represents an HTTP cookie.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class Cookie
|
||||
{
|
||||
/**
|
||||
* Handles dates as defined by RFC 2616 section 3.3.1, and also some other
|
||||
* non-standard, but common formats.
|
||||
*/
|
||||
private const DATE_FORMATS = [
|
||||
'D, d M Y H:i:s T',
|
||||
'D, d-M-y H:i:s T',
|
||||
'D, d-M-Y H:i:s T',
|
||||
'D, d-m-y H:i:s T',
|
||||
'D, d-m-Y H:i:s T',
|
||||
'D M j G:i:s Y',
|
||||
'D M d H:i:s Y T',
|
||||
];
|
||||
|
||||
protected string $value;
|
||||
protected ?string $expires = null;
|
||||
protected string $path;
|
||||
protected string $rawValue;
|
||||
|
||||
/**
|
||||
* Sets a cookie.
|
||||
*
|
||||
* @param string $name The cookie name
|
||||
* @param string|null $value The value of the cookie
|
||||
* @param string|null $expires The time the cookie expires
|
||||
* @param string|null $path The path on the server in which the cookie will be available on
|
||||
* @param string $domain The domain that the cookie is available
|
||||
* @param bool $secure Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client
|
||||
* @param bool $httponly The cookie httponly flag
|
||||
* @param bool $encodedValue Whether the value is encoded or not
|
||||
* @param string|null $samesite The cookie samesite attribute
|
||||
*/
|
||||
public function __construct(
|
||||
private string $name,
|
||||
?string $value,
|
||||
?string $expires = null,
|
||||
?string $path = null,
|
||||
private string $domain = '',
|
||||
private bool $secure = false,
|
||||
private bool $httponly = true,
|
||||
bool $encodedValue = false,
|
||||
private ?string $samesite = null,
|
||||
) {
|
||||
if ($encodedValue) {
|
||||
$this->rawValue = $value ?? '';
|
||||
$this->value = urldecode($this->rawValue);
|
||||
} else {
|
||||
$this->value = $value ?? '';
|
||||
$this->rawValue = rawurlencode($this->value);
|
||||
}
|
||||
$this->path = $path ?: '/';
|
||||
|
||||
if (null !== $expires) {
|
||||
$timestampAsDateTime = \DateTimeImmutable::createFromFormat('U', $expires);
|
||||
if (false === $timestampAsDateTime) {
|
||||
throw new UnexpectedValueException(\sprintf('The cookie expiration time "%s" is not valid.', $expires));
|
||||
}
|
||||
|
||||
$this->expires = $timestampAsDateTime->format('U');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTTP representation of the Cookie.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
$cookie = \sprintf('%s=%s', $this->name, $this->rawValue);
|
||||
|
||||
if (null !== $this->expires) {
|
||||
$dateTime = \DateTimeImmutable::createFromFormat('U', $this->expires, new \DateTimeZone('GMT'));
|
||||
$cookie .= '; expires='.str_replace('+0000', '', $dateTime->format(self::DATE_FORMATS[0]));
|
||||
}
|
||||
|
||||
if ('' !== $this->domain) {
|
||||
$cookie .= '; domain='.$this->domain;
|
||||
}
|
||||
|
||||
if ($this->path) {
|
||||
$cookie .= '; path='.$this->path;
|
||||
}
|
||||
|
||||
if ($this->secure) {
|
||||
$cookie .= '; secure';
|
||||
}
|
||||
|
||||
if ($this->httponly) {
|
||||
$cookie .= '; httponly';
|
||||
}
|
||||
|
||||
if (null !== $this->samesite) {
|
||||
$cookie .= '; samesite='.$this->samesite;
|
||||
}
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Cookie instance from a Set-Cookie header value.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function fromString(string $cookie, ?string $url = null): static
|
||||
{
|
||||
$parts = explode(';', $cookie);
|
||||
|
||||
if (!str_contains($parts[0], '=')) {
|
||||
throw new InvalidArgumentException(\sprintf('The cookie string "%s" is not valid.', $parts[0]));
|
||||
}
|
||||
|
||||
[$name, $value] = explode('=', array_shift($parts), 2);
|
||||
|
||||
$values = [
|
||||
'name' => trim($name),
|
||||
'value' => trim($value),
|
||||
'expires' => null,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => false,
|
||||
'httponly' => false,
|
||||
'passedRawValue' => true,
|
||||
'samesite' => null,
|
||||
];
|
||||
|
||||
if (null !== $url) {
|
||||
if (false === ($urlParts = parse_url($url)) || !isset($urlParts['host'])) {
|
||||
throw new InvalidArgumentException(\sprintf('The URL "%s" is not valid.', $url));
|
||||
}
|
||||
|
||||
$values['domain'] = $urlParts['host'];
|
||||
$values['path'] = isset($urlParts['path']) ? substr($urlParts['path'], 0, strrpos($urlParts['path'], '/')) : '';
|
||||
}
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
|
||||
if ('secure' === strtolower($part)) {
|
||||
// Ignore the secure flag if the original URI is not given or is not HTTPS
|
||||
if (null === $url || !isset($urlParts['scheme']) || 'https' !== $urlParts['scheme']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values['secure'] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('httponly' === strtolower($part)) {
|
||||
$values['httponly'] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (2 === \count($elements = explode('=', $part, 2))) {
|
||||
if ('expires' === strtolower($elements[0])) {
|
||||
$elements[1] = self::parseDate($elements[1]);
|
||||
}
|
||||
|
||||
$values[strtolower($elements[0])] = $elements[1];
|
||||
}
|
||||
}
|
||||
|
||||
return new static(
|
||||
$values['name'],
|
||||
$values['value'],
|
||||
$values['expires'],
|
||||
$values['path'],
|
||||
$values['domain'],
|
||||
$values['secure'],
|
||||
$values['httponly'],
|
||||
$values['passedRawValue'],
|
||||
$values['samesite']
|
||||
);
|
||||
}
|
||||
|
||||
private static function parseDate(string $dateValue): ?string
|
||||
{
|
||||
// trim single quotes around date if present
|
||||
if (($length = \strlen($dateValue)) > 1 && "'" === $dateValue[0] && "'" === $dateValue[$length - 1]) {
|
||||
$dateValue = substr($dateValue, 1, -1);
|
||||
}
|
||||
|
||||
foreach (self::DATE_FORMATS as $dateFormat) {
|
||||
if (false !== $date = \DateTimeImmutable::createFromFormat($dateFormat, $dateValue, new \DateTimeZone('GMT'))) {
|
||||
return $date->format('U');
|
||||
}
|
||||
}
|
||||
|
||||
// attempt a fallback for unusual formatting
|
||||
if (false !== $date = date_create_immutable($dateValue, new \DateTimeZone('GMT'))) {
|
||||
return $date->format('U');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the name of the cookie.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the cookie.
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw value of the cookie.
|
||||
*/
|
||||
public function getRawValue(): string
|
||||
{
|
||||
return $this->rawValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the expires time of the cookie.
|
||||
*/
|
||||
public function getExpiresTime(): ?string
|
||||
{
|
||||
return $this->expires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path of the cookie.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the domain of the cookie.
|
||||
*/
|
||||
public function getDomain(): string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the secure flag of the cookie.
|
||||
*/
|
||||
public function isSecure(): bool
|
||||
{
|
||||
return $this->secure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the httponly flag of the cookie.
|
||||
*/
|
||||
public function isHttpOnly(): bool
|
||||
{
|
||||
return $this->httponly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the cookie has expired.
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return null !== $this->expires && 0 != $this->expires && $this->expires <= time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the samesite attribute of the cookie.
|
||||
*/
|
||||
public function getSameSite(): ?string
|
||||
{
|
||||
return $this->samesite;
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* CookieJar.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class CookieJar
|
||||
{
|
||||
protected array $cookieJar = [];
|
||||
|
||||
public function set(Cookie $cookie): void
|
||||
{
|
||||
$this->cookieJar[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a cookie by name.
|
||||
*
|
||||
* You should never use an empty domain, but if you do so,
|
||||
* this method returns the first cookie for the given name/path
|
||||
* (this behavior ensures a BC behavior with previous versions of
|
||||
* Symfony).
|
||||
*/
|
||||
public function get(string $name, string $path = '/', ?string $domain = null): ?Cookie
|
||||
{
|
||||
$this->flushExpiredCookies();
|
||||
|
||||
foreach ($this->cookieJar as $cookieDomain => $pathCookies) {
|
||||
if ($cookieDomain && $domain) {
|
||||
$cookieDomain = '.'.ltrim($cookieDomain, '.');
|
||||
if (!str_ends_with('.'.$domain, $cookieDomain)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pathCookies as $cookiePath => $namedCookies) {
|
||||
if (!str_starts_with($path, $cookiePath)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($namedCookies[$name])) {
|
||||
return $namedCookies[$name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a cookie by name.
|
||||
*
|
||||
* You should never use an empty domain, but if you do so,
|
||||
* all cookies for the given name/path expire (this behavior
|
||||
* ensures a BC behavior with previous versions of Symfony).
|
||||
*/
|
||||
public function expire(string $name, ?string $path = '/', ?string $domain = null): void
|
||||
{
|
||||
$path ??= '/';
|
||||
|
||||
if (!$domain) {
|
||||
// an empty domain means any domain
|
||||
// this should never happen but it allows for a better BC
|
||||
$domains = array_keys($this->cookieJar);
|
||||
} else {
|
||||
$domains = [$domain];
|
||||
}
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
unset($this->cookieJar[$domain][$path][$name]);
|
||||
|
||||
if (empty($this->cookieJar[$domain][$path])) {
|
||||
unset($this->cookieJar[$domain][$path]);
|
||||
|
||||
if (empty($this->cookieJar[$domain])) {
|
||||
unset($this->cookieJar[$domain]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the cookies from the jar.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->cookieJar = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cookie jar from a response Set-Cookie headers.
|
||||
*
|
||||
* @param string[] $setCookies Set-Cookie headers from an HTTP response
|
||||
*/
|
||||
public function updateFromSetCookie(array $setCookies, ?string $uri = null): void
|
||||
{
|
||||
$cookies = [];
|
||||
|
||||
foreach ($setCookies as $cookie) {
|
||||
foreach (explode(',', $cookie) as $i => $part) {
|
||||
if (0 === $i || preg_match('/^(?P<token>\s*[0-9A-Za-z!#\$%\&\'\*\+\-\.^_`\|~]+)=/', $part)) {
|
||||
$cookies[] = ltrim($part);
|
||||
} else {
|
||||
$cookies[\count($cookies) - 1] .= ','.$part;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($cookies as $cookie) {
|
||||
try {
|
||||
$this->set(Cookie::fromString($cookie, $uri));
|
||||
} catch (InvalidArgumentException) {
|
||||
// invalid cookies are just ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cookie jar from a Response object.
|
||||
*/
|
||||
public function updateFromResponse(Response $response, ?string $uri = null): void
|
||||
{
|
||||
$this->updateFromSetCookie($response->getHeader('Set-Cookie', false), $uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns not yet expired cookies.
|
||||
*
|
||||
* @return Cookie[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$this->flushExpiredCookies();
|
||||
|
||||
$flattenedCookies = [];
|
||||
foreach ($this->cookieJar as $path) {
|
||||
foreach ($path as $cookies) {
|
||||
foreach ($cookies as $cookie) {
|
||||
$flattenedCookies[] = $cookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $flattenedCookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns not yet expired cookie values for the given URI.
|
||||
*/
|
||||
public function allValues(string $uri, bool $returnsRawValue = false): array
|
||||
{
|
||||
$this->flushExpiredCookies();
|
||||
|
||||
$parts = array_replace(['path' => '/'], parse_url($uri));
|
||||
$cookies = [];
|
||||
foreach ($this->cookieJar as $domain => $pathCookies) {
|
||||
if ($domain) {
|
||||
$domain = '.'.ltrim($domain, '.');
|
||||
if (!str_ends_with('.'.$parts['host'], $domain)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pathCookies as $path => $namedCookies) {
|
||||
if (!str_starts_with($parts['path'], $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($namedCookies as $cookie) {
|
||||
if ($cookie->isSecure() && 'https' !== $parts['scheme']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cookies[$cookie->getName()] = $returnsRawValue ? $cookie->getRawValue() : $cookie->getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns not yet expired raw cookie values for the given URI.
|
||||
*/
|
||||
public function allRawValues(string $uri): array
|
||||
{
|
||||
return $this->allValues($uri, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all expired cookies.
|
||||
*/
|
||||
public function flushExpiredCookies(): void
|
||||
{
|
||||
foreach ($this->cookieJar as $domain => $pathCookies) {
|
||||
foreach ($pathCookies as $path => $namedCookies) {
|
||||
foreach ($namedCookies as $name => $cookie) {
|
||||
if ($cookie->isExpired()) {
|
||||
unset($this->cookieJar[$domain][$path][$name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class BadMethodCallException extends \BadMethodCallException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
/**
|
||||
* Base ExceptionInterface for the BrowserKit component.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
interface ExceptionInterface extends \Throwable
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class JsonException extends \JsonException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class LogicException extends \LogicException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class RuntimeException extends \RuntimeException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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\BrowserKit\Exception;
|
||||
|
||||
class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
+94
@@ -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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\LogicException;
|
||||
|
||||
/**
|
||||
* History.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class History
|
||||
{
|
||||
protected array $stack = [];
|
||||
protected int $position = -1;
|
||||
|
||||
/**
|
||||
* Clears the history.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->stack = [];
|
||||
$this->position = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a Request to the history.
|
||||
*/
|
||||
public function add(Request $request): void
|
||||
{
|
||||
$this->stack = \array_slice($this->stack, 0, $this->position + 1);
|
||||
$this->stack[] = clone $request;
|
||||
$this->position = \count($this->stack) - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the history is empty.
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return 0 === \count($this->stack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes back in the history.
|
||||
*
|
||||
* @throws LogicException if the stack is already on the first page
|
||||
*/
|
||||
public function back(): Request
|
||||
{
|
||||
if ($this->position < 1) {
|
||||
throw new LogicException('You are already on the first page.');
|
||||
}
|
||||
|
||||
return clone $this->stack[--$this->position];
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes forward in the history.
|
||||
*
|
||||
* @throws LogicException if the stack is already on the last page
|
||||
*/
|
||||
public function forward(): Request
|
||||
{
|
||||
if ($this->position > \count($this->stack) - 2) {
|
||||
throw new LogicException('You are already on the last page.');
|
||||
}
|
||||
|
||||
return clone $this->stack[++$this->position];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current element in the history.
|
||||
*
|
||||
* @throws LogicException if the stack is empty
|
||||
*/
|
||||
public function current(): Request
|
||||
{
|
||||
if (-1 === $this->position) {
|
||||
throw new LogicException('The page history is empty.');
|
||||
}
|
||||
|
||||
return clone $this->stack[$this->position];
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\LogicException;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\Mime\Part\AbstractPart;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
|
||||
use Symfony\Component\Mime\Part\TextPart;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* An implementation of a browser using the HttpClient component
|
||||
* to make real HTTP requests.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @template-extends AbstractBrowser<Request, Response>
|
||||
*/
|
||||
class HttpBrowser extends AbstractBrowser
|
||||
{
|
||||
private HttpClientInterface $client;
|
||||
|
||||
public function __construct(?HttpClientInterface $client = null, ?History $history = null, ?CookieJar $cookieJar = null)
|
||||
{
|
||||
if (!$client && !class_exists(HttpClient::class)) {
|
||||
throw new LogicException(\sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__));
|
||||
}
|
||||
|
||||
$this->client = $client ?? HttpClient::create();
|
||||
|
||||
parent::__construct([], $history, $cookieJar);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
protected function doRequest(object $request): Response
|
||||
{
|
||||
$headers = $this->getHeaders($request);
|
||||
[$body, $extraHeaders] = $this->getBodyAndExtraHeaders($request, $headers);
|
||||
|
||||
$response = $this->client->request($request->getMethod(), $request->getUri(), [
|
||||
'headers' => array_merge($headers, $extraHeaders),
|
||||
'body' => $body,
|
||||
'max_redirects' => 0,
|
||||
]);
|
||||
|
||||
return new Response($response->getContent(false), $response->getStatusCode(), $response->getHeaders(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array [$body, $headers]
|
||||
*/
|
||||
private function getBodyAndExtraHeaders(Request $request, array $headers): array
|
||||
{
|
||||
if (\in_array($request->getMethod(), ['GET', 'HEAD']) && !isset($headers['content-type'])) {
|
||||
return ['', []];
|
||||
}
|
||||
|
||||
if (!class_exists(AbstractPart::class)) {
|
||||
throw new LogicException('You cannot pass non-empty bodies as the Mime component is not installed. Try running "composer require symfony/mime".');
|
||||
}
|
||||
|
||||
if (null !== $content = $request->getContent()) {
|
||||
if (isset($headers['content-type'])) {
|
||||
return [$content, []];
|
||||
}
|
||||
|
||||
$part = new TextPart($content, 'utf-8', 'plain', '8bit');
|
||||
|
||||
return [$part->bodyToString(), $part->getPreparedHeaders()->toArray()];
|
||||
}
|
||||
|
||||
$fields = $request->getParameters();
|
||||
|
||||
if ($uploadedFiles = $this->getUploadedFiles($request->getFiles())) {
|
||||
$part = new FormDataPart(array_replace_recursive($fields, $uploadedFiles));
|
||||
|
||||
return [$part->bodyToIterable(), $part->getPreparedHeaders()->toArray()];
|
||||
}
|
||||
|
||||
if (!$fields) {
|
||||
return ['', []];
|
||||
}
|
||||
|
||||
array_walk_recursive($fields, $caster = static function (&$v) use (&$caster) {
|
||||
if (\is_object($v)) {
|
||||
if ($vars = get_object_vars($v)) {
|
||||
array_walk_recursive($vars, $caster);
|
||||
$v = $vars;
|
||||
} elseif ($v instanceof \Stringable) {
|
||||
$v = (string) $v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [http_build_query($fields, '', '&'), ['Content-Type' => 'application/x-www-form-urlencoded']];
|
||||
}
|
||||
|
||||
protected function getHeaders(Request $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($request->getServer() as $key => $value) {
|
||||
$key = strtolower(str_replace('_', '-', $key));
|
||||
$contentHeaders = ['content-length' => true, 'content-md5' => true, 'content-type' => true];
|
||||
if (str_starts_with($key, 'http-')) {
|
||||
$headers[substr($key, 5)] = $value;
|
||||
} elseif (isset($contentHeaders[$key])) {
|
||||
// CONTENT_* are not prefixed with HTTP_
|
||||
$headers[$key] = $value;
|
||||
}
|
||||
}
|
||||
$cookies = [];
|
||||
foreach ($this->getCookieJar()->allRawValues($request->getUri()) as $name => $value) {
|
||||
$cookies[] = $name.'='.$value;
|
||||
}
|
||||
if ($cookies) {
|
||||
$headers['cookie'] = implode('; ', $cookies);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively go through the list. If the file has a tmp_name, convert it to a DataPart.
|
||||
* Keep the original hierarchy.
|
||||
*/
|
||||
private function getUploadedFiles(array $files): array
|
||||
{
|
||||
$uploadedFiles = [];
|
||||
foreach ($files as $name => $file) {
|
||||
if (!\is_array($file)) {
|
||||
return $uploadedFiles;
|
||||
}
|
||||
if (!isset($file['tmp_name'])) {
|
||||
$uploadedFiles[$name] = $this->getUploadedFiles($file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' === $file['tmp_name']) {
|
||||
$uploadedFiles[$name] = new DataPart('', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadedFiles[$name] = DataPart::fromPath($file['tmp_name'], $file['name']);
|
||||
}
|
||||
|
||||
return $uploadedFiles;
|
||||
}
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2004-present Fabien Potencier
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
BrowserKit Component
|
||||
====================
|
||||
|
||||
The BrowserKit component simulates the behavior of a web browser, allowing you
|
||||
to make requests, click on links and submit forms programmatically.
|
||||
|
||||
The component comes with a concrete implementation that uses the HttpClient
|
||||
component to make real HTTP requests.
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
* [Documentation](https://symfony.com/doc/current/components/browser_kit/introduction.html)
|
||||
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
|
||||
* [Report issues](https://github.com/symfony/symfony/issues) and
|
||||
[send Pull Requests](https://github.com/symfony/symfony/pulls)
|
||||
in the [main Symfony repository](https://github.com/symfony/symfony)
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
/**
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
/**
|
||||
* @param string $uri The request URI
|
||||
* @param string $method The HTTP method request
|
||||
* @param array $parameters The request parameters
|
||||
* @param array $files An array of uploaded files
|
||||
* @param array $cookies An array of cookies
|
||||
* @param array $server An array of server parameters
|
||||
* @param string|null $content The raw body data
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $uri,
|
||||
protected string $method,
|
||||
protected array $parameters = [],
|
||||
protected array $files = [],
|
||||
protected array $cookies = [],
|
||||
protected array $server = [],
|
||||
protected ?string $content = null,
|
||||
) {
|
||||
array_walk_recursive($parameters, static function (&$value) {
|
||||
$value = (string) $value;
|
||||
});
|
||||
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request URI.
|
||||
*/
|
||||
public function getUri(): string
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request HTTP method.
|
||||
*/
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request parameters.
|
||||
*/
|
||||
public function getParameters(): array
|
||||
{
|
||||
return $this->parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request server files.
|
||||
*/
|
||||
public function getFiles(): array
|
||||
{
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request cookies.
|
||||
*/
|
||||
public function getCookies(): array
|
||||
{
|
||||
return $this->cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request server parameters.
|
||||
*/
|
||||
public function getServer(): array
|
||||
{
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request raw body data.
|
||||
*/
|
||||
public function getContent(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?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\BrowserKit;
|
||||
|
||||
use Symfony\Component\BrowserKit\Exception\JsonException;
|
||||
|
||||
/**
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
final class Response
|
||||
{
|
||||
private array $jsonData;
|
||||
|
||||
/**
|
||||
* The headers array is a set of key/value pairs. If a header is present multiple times
|
||||
* then the value is an array of all the values.
|
||||
*
|
||||
* @param string $content The content of the response
|
||||
* @param int $status The response status code (302 "Found" by default)
|
||||
* @param array $headers An array of headers
|
||||
*/
|
||||
public function __construct(
|
||||
private string $content = '',
|
||||
private int $status = 200,
|
||||
private array $headers = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the response object to string containing all headers and the response content.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
$headers = '';
|
||||
foreach ($this->headers as $name => $value) {
|
||||
if (\is_string($value)) {
|
||||
$headers .= \sprintf("%s: %s\n", $name, $value);
|
||||
} else {
|
||||
foreach ($value as $headerValue) {
|
||||
$headers .= \sprintf("%s: %s\n", $name, $headerValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $headers."\n".$this->content;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|array|null The first header value if $first is true, an array of values otherwise
|
||||
*/
|
||||
public function getHeader(string $header, bool $first = true): string|array|null
|
||||
{
|
||||
$normalizedHeader = str_replace('-', '_', strtolower($header));
|
||||
foreach ($this->headers as $key => $value) {
|
||||
if (str_replace('-', '_', strtolower($key)) === $normalizedHeader) {
|
||||
if ($first) {
|
||||
return \is_array($value) ? (\count($value) ? $value[0] : '') : $value;
|
||||
}
|
||||
|
||||
return \is_array($value) ? $value : [$value];
|
||||
}
|
||||
}
|
||||
|
||||
return $first ? null : [];
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
if (isset($this->jsonData)) {
|
||||
return $this->jsonData;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = json_decode($this->content, true, flags: \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new JsonException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
if (!\is_array($content)) {
|
||||
throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content)));
|
||||
}
|
||||
|
||||
return $this->jsonData = $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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\BrowserKit\Test\Constraint;
|
||||
|
||||
use PHPUnit\Framework\Constraint\Constraint;
|
||||
use Symfony\Component\BrowserKit\AbstractBrowser;
|
||||
|
||||
final class BrowserCookieValueSame extends Constraint
|
||||
{
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $value,
|
||||
private bool $raw = false,
|
||||
private string $path = '/',
|
||||
private ?string $domain = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$str = \sprintf('has cookie "%s"', $this->name);
|
||||
if ('/' !== $this->path) {
|
||||
$str .= \sprintf(' with path "%s"', $this->path);
|
||||
}
|
||||
if ($this->domain) {
|
||||
$str .= \sprintf(' for domain "%s"', $this->domain);
|
||||
}
|
||||
$str .= \sprintf(' with %svalue "%s"', $this->raw ? 'raw ' : '', $this->value);
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AbstractBrowser $browser
|
||||
*/
|
||||
protected function matches($browser): bool
|
||||
{
|
||||
$cookie = $browser->getCookieJar()->get($this->name, $this->path, $this->domain);
|
||||
if (!$cookie) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->value === ($this->raw ? $cookie->getRawValue() : $cookie->getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AbstractBrowser $browser
|
||||
*/
|
||||
protected function failureDescription($browser): string
|
||||
{
|
||||
return 'the Browser '.$this->toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\BrowserKit\Test\Constraint;
|
||||
|
||||
use PHPUnit\Framework\Constraint\Constraint;
|
||||
use Symfony\Component\BrowserKit\AbstractBrowser;
|
||||
|
||||
final class BrowserHasCookie extends Constraint
|
||||
{
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $path = '/',
|
||||
private ?string $domain = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$str = \sprintf('has cookie "%s"', $this->name);
|
||||
if ('/' !== $this->path) {
|
||||
$str .= \sprintf(' with path "%s"', $this->path);
|
||||
}
|
||||
if ($this->domain) {
|
||||
$str .= \sprintf(' for domain "%s"', $this->domain);
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AbstractBrowser $browser
|
||||
*/
|
||||
protected function matches($browser): bool
|
||||
{
|
||||
return null !== $browser->getCookieJar()->get($this->name, $this->path, $this->domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AbstractBrowser $browser
|
||||
*/
|
||||
protected function failureDescription($browser): string
|
||||
{
|
||||
return 'the Browser '.$this->toString();
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "symfony/browser-kit",
|
||||
"type": "library",
|
||||
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
||||
"keywords": [],
|
||||
"homepage": "https://symfony.com",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/dom-crawler": "^6.4|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^6.4|^7.0",
|
||||
"symfony/http-client": "^6.4|^7.0",
|
||||
"symfony/mime": "^6.4|^7.0",
|
||||
"symfony/process": "^6.4|^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": { "Symfony\\Component\\BrowserKit\\": "" },
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
}
|
||||
+1346
File diff suppressed because it is too large
Load Diff
+110
@@ -0,0 +1,110 @@
|
||||
<?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\Attribute;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\Suggestion;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PARAMETER)]
|
||||
class Argument
|
||||
{
|
||||
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
|
||||
|
||||
private string|bool|int|float|array|null $default = null;
|
||||
private array|\Closure $suggestedValues;
|
||||
private ?int $mode = null;
|
||||
private string $function = '';
|
||||
|
||||
/**
|
||||
* Represents a console command <argument> definition.
|
||||
*
|
||||
* If unset, the `name` value will be inferred from the parameter definition.
|
||||
*
|
||||
* @param array<string|Suggestion>|callable(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*/
|
||||
public function __construct(
|
||||
public string $description = '',
|
||||
public string $name = '',
|
||||
array|callable $suggestedValues = [],
|
||||
) {
|
||||
$this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function tryFrom(\ReflectionParameter $parameter): ?self
|
||||
{
|
||||
/** @var self $self */
|
||||
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
|
||||
$self->function = $function->class.'::'.$function->name;
|
||||
} else {
|
||||
$self->function = $function->name;
|
||||
}
|
||||
|
||||
$type = $parameter->getType();
|
||||
$name = $parameter->getName();
|
||||
|
||||
if (!$type instanceof \ReflectionNamedType) {
|
||||
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name, $self->function));
|
||||
}
|
||||
|
||||
$parameterTypeName = $type->getName();
|
||||
|
||||
if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) {
|
||||
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
|
||||
}
|
||||
|
||||
if (!$self->name) {
|
||||
$self->name = (new UnicodeString($name))->kebab();
|
||||
}
|
||||
|
||||
$self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
|
||||
|
||||
$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
|
||||
if ('array' === $parameterTypeName) {
|
||||
$self->mode |= InputArgument::IS_ARRAY;
|
||||
}
|
||||
|
||||
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
|
||||
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
|
||||
}
|
||||
|
||||
return $self;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function toInputArgument(): InputArgument
|
||||
{
|
||||
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;
|
||||
|
||||
return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function resolveValue(InputInterface $input): mixed
|
||||
{
|
||||
return $input->getArgument($this->name);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?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\Attribute;
|
||||
|
||||
/**
|
||||
* Service tag to autoconfigure commands.
|
||||
*
|
||||
* @final since Symfony 7.3
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
class AsCommand
|
||||
{
|
||||
/**
|
||||
* @param string $name The name of the command, used when calling it (i.e. "cache:clear")
|
||||
* @param string|null $description The description of the command, displayed with the help page
|
||||
* @param string[] $aliases The list of aliases of the command. The command will be executed when using one of them (i.e. "cache:clean")
|
||||
* @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command
|
||||
* @param string|null $help The help content of the command, displayed with the help page
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?string $description = null,
|
||||
array $aliases = [],
|
||||
bool $hidden = false,
|
||||
public ?string $help = null,
|
||||
) {
|
||||
if (!$hidden && !$aliases) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = explode('|', $name);
|
||||
$name = array_merge($name, $aliases);
|
||||
|
||||
if ($hidden && '' !== $name[0]) {
|
||||
array_unshift($name, '');
|
||||
}
|
||||
|
||||
$this->name = implode('|', $name);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<?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\Attribute;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\Suggestion;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PARAMETER)]
|
||||
class Option
|
||||
{
|
||||
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
|
||||
private const ALLOWED_UNION_TYPES = ['bool|string', 'bool|int', 'bool|float'];
|
||||
|
||||
private string|bool|int|float|array|null $default = null;
|
||||
private array|\Closure $suggestedValues;
|
||||
private ?int $mode = null;
|
||||
private string $typeName = '';
|
||||
private bool $allowNull = false;
|
||||
private string $function = '';
|
||||
|
||||
/**
|
||||
* Represents a console command --option definition.
|
||||
*
|
||||
* If unset, the `name` value will be inferred from the parameter definition.
|
||||
*
|
||||
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
|
||||
* @param array<string|Suggestion>|callable(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*/
|
||||
public function __construct(
|
||||
public string $description = '',
|
||||
public string $name = '',
|
||||
public array|string|null $shortcut = null,
|
||||
array|callable $suggestedValues = [],
|
||||
) {
|
||||
$this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function tryFrom(\ReflectionParameter $parameter): ?self
|
||||
{
|
||||
/** @var self $self */
|
||||
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
|
||||
$self->function = $function->class.'::'.$function->name;
|
||||
} else {
|
||||
$self->function = $function->name;
|
||||
}
|
||||
|
||||
$name = $parameter->getName();
|
||||
$type = $parameter->getType();
|
||||
|
||||
if (!$parameter->isDefaultValueAvailable()) {
|
||||
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must declare a default value.', $name, $self->function));
|
||||
}
|
||||
|
||||
if (!$self->name) {
|
||||
$self->name = (new UnicodeString($name))->kebab();
|
||||
}
|
||||
|
||||
$self->default = $parameter->getDefaultValue();
|
||||
$self->allowNull = $parameter->allowsNull();
|
||||
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
return $self->handleUnion($type);
|
||||
}
|
||||
|
||||
if (!$type instanceof \ReflectionNamedType) {
|
||||
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped or Intersection types are not supported for command options.', $name, $self->function));
|
||||
}
|
||||
|
||||
$self->typeName = $type->getName();
|
||||
|
||||
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
|
||||
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
|
||||
}
|
||||
|
||||
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
|
||||
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must not be nullable when it has a default boolean value.', $name, $self->function));
|
||||
}
|
||||
|
||||
if ($self->allowNull && null !== $self->default) {
|
||||
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must either be not-nullable or have a default of null.', $name, $self->function));
|
||||
}
|
||||
|
||||
if ('bool' === $self->typeName) {
|
||||
$self->mode = InputOption::VALUE_NONE;
|
||||
if (false !== $self->default) {
|
||||
$self->mode |= InputOption::VALUE_NEGATABLE;
|
||||
}
|
||||
} elseif ('array' === $self->typeName) {
|
||||
$self->mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY;
|
||||
} else {
|
||||
$self->mode = InputOption::VALUE_REQUIRED;
|
||||
}
|
||||
|
||||
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
|
||||
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
|
||||
}
|
||||
|
||||
return $self;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function toInputOption(): InputOption
|
||||
{
|
||||
$default = InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $this->mode) ? null : $this->default;
|
||||
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;
|
||||
|
||||
return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $default, $suggestedValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function resolveValue(InputInterface $input): mixed
|
||||
{
|
||||
$value = $input->getOption($this->name);
|
||||
|
||||
if (null === $value && \in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('bool' !== $this->typeName) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($this->allowNull && null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value ?? $this->default;
|
||||
}
|
||||
|
||||
private function handleUnion(\ReflectionUnionType $type): self
|
||||
{
|
||||
$types = array_map(
|
||||
static fn (\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null,
|
||||
$type->getTypes(),
|
||||
);
|
||||
|
||||
sort($types);
|
||||
|
||||
$this->typeName = implode('|', array_filter($types));
|
||||
|
||||
if (!\in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
|
||||
throw new LogicException(\sprintf('The union type for parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $this->name, $this->function, implode('", "', self::ALLOWED_UNION_TYPES)));
|
||||
}
|
||||
|
||||
if (false !== $this->default) {
|
||||
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must have a default value of false.', $this->name, $this->function));
|
||||
}
|
||||
|
||||
$this->mode = InputOption::VALUE_OPTIONAL;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
Vendored
+299
@@ -0,0 +1,299 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
7.3
|
||||
---
|
||||
|
||||
* Add `TreeHelper` and `TreeStyle` to display tree-like structures
|
||||
* Add `SymfonyStyle::createTree()`
|
||||
* Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options
|
||||
* Deprecate not declaring the parameter type in callable commands defined through `setCode` method
|
||||
* Add support for help definition via `AsCommand` attribute
|
||||
* Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute
|
||||
* Add support for Markdown format in `Table`
|
||||
* Add support for `LockableTrait` in invokable commands
|
||||
* Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()`
|
||||
* Mark `#[AsCommand]` attribute as `@final`
|
||||
* Add support for `SignalableCommandInterface` with invokable commands
|
||||
|
||||
7.2
|
||||
---
|
||||
|
||||
* Add support for `FORCE_COLOR` environment variable
|
||||
* Add `verbosity` argument to `mustRun` process helper method
|
||||
* [BC BREAK] Add silent verbosity (`--silent`/`SHELL_VERBOSITY=-2`) to suppress all output, including errors
|
||||
* Add `OutputInterface::isSilent()`, `Output::isSilent()`, `OutputStyle::isSilent()` methods
|
||||
* Add a configurable finished indicator to the progress indicator to show that the progress is finished
|
||||
* Add ability to schedule alarm signals and a `ConsoleAlarmEvent`
|
||||
|
||||
7.1
|
||||
---
|
||||
|
||||
* Add `ArgvInput::getRawTokens()`
|
||||
|
||||
7.0
|
||||
---
|
||||
|
||||
* Add method `__toString()` to `InputInterface`
|
||||
* Remove `Command::$defaultName` and `Command::$defaultDescription`, use the `AsCommand` attribute instead
|
||||
* Require explicit argument when calling `*Command::setApplication()`, `*FormatterStyle::setForeground/setBackground()`, `Helper::setHelpSet()`, `Input*::setDefault()` and `Question::setAutocompleterCallback/setValidator()`
|
||||
* Remove `StringInput::REGEX_STRING`
|
||||
|
||||
6.4
|
||||
---
|
||||
|
||||
* Add `SignalMap` to map signal value to its name
|
||||
* Multi-line text in vertical tables is aligned properly
|
||||
* The application can also catch errors with `Application::setCatchErrors(true)`
|
||||
* Add `RunCommandMessage` and `RunCommandMessageHandler`
|
||||
* Dispatch `ConsoleTerminateEvent` after an exit on signal handling and add `ConsoleTerminateEvent::getInterruptingSignal()`
|
||||
|
||||
6.3
|
||||
---
|
||||
|
||||
* Add support for choosing exit code while handling signal, or to not exit at all
|
||||
* Add `ProgressBar::setPlaceholderFormatter` to set a placeholder attached to a instance, instead of being global.
|
||||
* Add `ReStructuredTextDescriptor`
|
||||
|
||||
6.2
|
||||
---
|
||||
|
||||
* Improve truecolor terminal detection in some cases
|
||||
* Add support for 256 color terminals (conversion from Ansi24 to Ansi8 if terminal is capable of it)
|
||||
* Deprecate calling `*Command::setApplication()`, `*FormatterStyle::setForeground/setBackground()`, `Helper::setHelpSet()`, `Input*::setDefault()`, `Question::setAutocompleterCallback/setValidator()`without any arguments
|
||||
* Change the signature of `OutputFormatterStyleInterface::setForeground/setBackground()` to `setForeground/setBackground(?string)`
|
||||
* Change the signature of `HelperInterface::setHelperSet()` to `setHelperSet(?HelperSet)`
|
||||
|
||||
6.1
|
||||
---
|
||||
|
||||
* Add support to display table vertically when calling setVertical()
|
||||
* Add method `__toString()` to `InputInterface`
|
||||
* Added `OutputWrapper` to prevent truncated URL in `SymfonyStyle::createBlock`.
|
||||
* Deprecate `Command::$defaultName` and `Command::$defaultDescription`, use the `AsCommand` attribute instead
|
||||
* Add suggested values for arguments and options in input definition, for input completion
|
||||
* Add `$resumeAt` parameter to `ProgressBar#start()`, so that one can easily 'resume' progress on longer tasks, and still get accurate `getEstimate()` and `getRemaining()` results.
|
||||
|
||||
6.0
|
||||
---
|
||||
|
||||
* `Command::setHidden()` has a default value (`true`) for `$hidden` parameter and is final
|
||||
* Remove `Helper::strlen()`, use `Helper::width()` instead
|
||||
* Remove `Helper::strlenWithoutDecoration()`, use `Helper::removeDecoration()` instead
|
||||
* `AddConsoleCommandPass` can not be configured anymore
|
||||
* Remove `HelperSet::setCommand()` and `getCommand()` without replacement
|
||||
|
||||
5.4
|
||||
---
|
||||
|
||||
* Add `TesterTrait::assertCommandIsSuccessful()` to test command
|
||||
* Deprecate `HelperSet::setCommand()` and `getCommand()` without replacement
|
||||
|
||||
5.3
|
||||
---
|
||||
|
||||
* Add `GithubActionReporter` to render annotations in a Github Action
|
||||
* Add `InputOption::VALUE_NEGATABLE` flag to handle `--foo`/`--no-foo` options
|
||||
* Add the `Command::$defaultDescription` static property and the `description` attribute
|
||||
on the `console.command` tag to allow the `list` command to instantiate commands lazily
|
||||
* Add option `--short` to the `list` command
|
||||
* Add support for bright colors
|
||||
* Add `#[AsCommand]` attribute for declaring commands on PHP 8
|
||||
* Add `Helper::width()` and `Helper::length()`
|
||||
* The `--ansi` and `--no-ansi` options now default to `null`.
|
||||
|
||||
5.2.0
|
||||
-----
|
||||
|
||||
* Added `SingleCommandApplication::setAutoExit()` to allow testing via `CommandTester`
|
||||
* added support for multiline responses to questions through `Question::setMultiline()`
|
||||
and `Question::isMultiline()`
|
||||
* Added `SignalRegistry` class to stack signals handlers
|
||||
* Added support for signals:
|
||||
* Added `Application::getSignalRegistry()` and `Application::setSignalsToDispatchEvent()` methods
|
||||
* Added `SignalableCommandInterface` interface
|
||||
* Added `TableCellStyle` class to customize table cell
|
||||
* Removed `php ` prefix invocation from help messages.
|
||||
|
||||
5.1.0
|
||||
-----
|
||||
|
||||
* `Command::setHidden()` is final since Symfony 5.1
|
||||
* Add `SingleCommandApplication`
|
||||
* Add `Cursor` class
|
||||
|
||||
5.0.0
|
||||
-----
|
||||
|
||||
* removed support for finding hidden commands using an abbreviation, use the full name instead
|
||||
* removed `TableStyle::setCrossingChar()` method in favor of `TableStyle::setDefaultCrossingChar()`
|
||||
* removed `TableStyle::setHorizontalBorderChar()` method in favor of `TableStyle::setDefaultCrossingChars()`
|
||||
* removed `TableStyle::getHorizontalBorderChar()` method in favor of `TableStyle::getBorderChars()`
|
||||
* removed `TableStyle::setVerticalBorderChar()` method in favor of `TableStyle::setVerticalBorderChars()`
|
||||
* removed `TableStyle::getVerticalBorderChar()` method in favor of `TableStyle::getBorderChars()`
|
||||
* removed support for returning `null` from `Command::execute()`, return `0` instead
|
||||
* `ProcessHelper::run()` accepts only `array|Symfony\Component\Process\Process` for its `command` argument
|
||||
* `Application::setDispatcher` accepts only `Symfony\Contracts\EventDispatcher\EventDispatcherInterface`
|
||||
for its `dispatcher` argument
|
||||
* renamed `Application::renderException()` and `Application::doRenderException()`
|
||||
to `renderThrowable()` and `doRenderThrowable()` respectively.
|
||||
|
||||
4.4.0
|
||||
-----
|
||||
|
||||
* deprecated finding hidden commands using an abbreviation, use the full name instead
|
||||
* added `Question::setTrimmable` default to true to allow the answer to be trimmed
|
||||
* added method `minSecondsBetweenRedraws()` and `maxSecondsBetweenRedraws()` on `ProgressBar`
|
||||
* `Application` implements `ResetInterface`
|
||||
* marked all dispatched event classes as `@final`
|
||||
* added support for displaying table horizontally
|
||||
* deprecated returning `null` from `Command::execute()`, return `0` instead
|
||||
* Deprecated the `Application::renderException()` and `Application::doRenderException()` methods,
|
||||
use `renderThrowable()` and `doRenderThrowable()` instead.
|
||||
* added support for the `NO_COLOR` env var (https://no-color.org/)
|
||||
|
||||
4.3.0
|
||||
-----
|
||||
|
||||
* added support for hyperlinks
|
||||
* added `ProgressBar::iterate()` method that simplify updating the progress bar when iterating
|
||||
* added `Question::setAutocompleterCallback()` to provide a callback function
|
||||
that dynamically generates suggestions as the user types
|
||||
|
||||
4.2.0
|
||||
-----
|
||||
|
||||
* allowed passing commands as `[$process, 'ENV_VAR' => 'value']` to
|
||||
`ProcessHelper::run()` to pass environment variables
|
||||
* deprecated passing a command as a string to `ProcessHelper::run()`,
|
||||
pass it the command as an array of its arguments instead
|
||||
* made the `ProcessHelper` class final
|
||||
* added `WrappableOutputFormatterInterface::formatAndWrap()` (implemented in `OutputFormatter`)
|
||||
* added `capture_stderr_separately` option to `CommandTester::execute()`
|
||||
|
||||
4.1.0
|
||||
-----
|
||||
|
||||
* added option to run suggested command if command is not found and only 1 alternative is available
|
||||
* added option to modify console output and print multiple modifiable sections
|
||||
* added support for iterable messages in output `write` and `writeln` methods
|
||||
|
||||
4.0.0
|
||||
-----
|
||||
|
||||
* `OutputFormatter` throws an exception when unknown options are used
|
||||
* removed `QuestionHelper::setInputStream()/getInputStream()`
|
||||
* removed `Application::getTerminalWidth()/getTerminalHeight()` and
|
||||
`Application::setTerminalDimensions()/getTerminalDimensions()`
|
||||
* removed `ConsoleExceptionEvent`
|
||||
* removed `ConsoleEvents::EXCEPTION`
|
||||
|
||||
3.4.0
|
||||
-----
|
||||
|
||||
* added `SHELL_VERBOSITY` env var to control verbosity
|
||||
* added `CommandLoaderInterface`, `FactoryCommandLoader` and PSR-11
|
||||
`ContainerCommandLoader` for commands lazy-loading
|
||||
* added a case-insensitive command name matching fallback
|
||||
* added static `Command::$defaultName/getDefaultName()`, allowing for
|
||||
commands to be registered at compile time in the application command loader.
|
||||
Setting the `$defaultName` property avoids the need for filling the `command`
|
||||
attribute on the `console.command` tag when using `AddConsoleCommandPass`.
|
||||
|
||||
3.3.0
|
||||
-----
|
||||
|
||||
* added `ExceptionListener`
|
||||
* added `AddConsoleCommandPass` (originally in FrameworkBundle)
|
||||
* [BC BREAK] `Input::getOption()` no longer returns the default value for options
|
||||
with value optional explicitly passed empty
|
||||
* added console.error event to catch exceptions thrown by other listeners
|
||||
* deprecated console.exception event in favor of console.error
|
||||
* added ability to handle `CommandNotFoundException` through the
|
||||
`console.error` event
|
||||
* deprecated default validation in `SymfonyQuestionHelper::ask`
|
||||
|
||||
3.2.0
|
||||
------
|
||||
|
||||
* added `setInputs()` method to CommandTester for ease testing of commands expecting inputs
|
||||
* added `setStream()` and `getStream()` methods to Input (implement StreamableInputInterface)
|
||||
* added StreamableInputInterface
|
||||
* added LockableTrait
|
||||
|
||||
3.1.0
|
||||
-----
|
||||
|
||||
* added truncate method to FormatterHelper
|
||||
* added setColumnWidth(s) method to Table
|
||||
|
||||
2.8.3
|
||||
-----
|
||||
|
||||
* remove readline support from the question helper as it caused issues
|
||||
|
||||
2.8.0
|
||||
-----
|
||||
|
||||
* use readline for user input in the question helper when available to allow
|
||||
the use of arrow keys
|
||||
|
||||
2.6.0
|
||||
-----
|
||||
|
||||
* added a Process helper
|
||||
* added a DebugFormatter helper
|
||||
|
||||
2.5.0
|
||||
-----
|
||||
|
||||
* deprecated the dialog helper (use the question helper instead)
|
||||
* deprecated TableHelper in favor of Table
|
||||
* deprecated ProgressHelper in favor of ProgressBar
|
||||
* added ConsoleLogger
|
||||
* added a question helper
|
||||
* added a way to set the process name of a command
|
||||
* added a way to set a default command instead of `ListCommand`
|
||||
|
||||
2.4.0
|
||||
-----
|
||||
|
||||
* added a way to force terminal dimensions
|
||||
* added a convenient method to detect verbosity level
|
||||
* [BC BREAK] made descriptors use output instead of returning a string
|
||||
|
||||
2.3.0
|
||||
-----
|
||||
|
||||
* added multiselect support to the select dialog helper
|
||||
* added Table Helper for tabular data rendering
|
||||
* added support for events in `Application`
|
||||
* added a way to normalize EOLs in `ApplicationTester::getDisplay()` and `CommandTester::getDisplay()`
|
||||
* added a way to set the progress bar progress via the `setCurrent` method
|
||||
* added support for multiple InputOption shortcuts, written as `'-a|-b|-c'`
|
||||
* added two additional verbosity levels, VERBOSITY_VERY_VERBOSE and VERBOSITY_DEBUG
|
||||
|
||||
2.2.0
|
||||
-----
|
||||
|
||||
* added support for colorization on Windows via ConEmu
|
||||
* add a method to Dialog Helper to ask for a question and hide the response
|
||||
* added support for interactive selections in console (DialogHelper::select())
|
||||
* added support for autocompletion as you type in Dialog Helper
|
||||
|
||||
2.1.0
|
||||
-----
|
||||
|
||||
* added ConsoleOutputInterface
|
||||
* added the possibility to disable a command (Command::isEnabled())
|
||||
* added suggestions when a command does not exist
|
||||
* added a --raw option to the list command
|
||||
* added support for STDERR in the console output class (errors are now sent
|
||||
to STDERR)
|
||||
* made the defaults (helper set, commands, input definition) in Application
|
||||
more easily customizable
|
||||
* added support for the shell even if readline is not available
|
||||
* added support for process isolation in Symfony shell via
|
||||
`--process-isolation` switch
|
||||
* added support for `--`, which disables options parsing after that point
|
||||
(tokens will be parsed as arguments)
|
||||
@@ -0,0 +1,97 @@
|
||||
<?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\CI;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Utility class for Github actions.
|
||||
*
|
||||
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
|
||||
*/
|
||||
class GithubActionReporter
|
||||
{
|
||||
/**
|
||||
* @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L80-L85
|
||||
*/
|
||||
private const ESCAPED_DATA = [
|
||||
'%' => '%25',
|
||||
"\r" => '%0D',
|
||||
"\n" => '%0A',
|
||||
];
|
||||
|
||||
/**
|
||||
* @see https://github.com/actions/toolkit/blob/5e5e1b7aacba68a53836a34db4a288c3c1c1585b/packages/core/src/command.ts#L87-L94
|
||||
*/
|
||||
private const ESCAPED_PROPERTIES = [
|
||||
'%' => '%25',
|
||||
"\r" => '%0D',
|
||||
"\n" => '%0A',
|
||||
':' => '%3A',
|
||||
',' => '%2C',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function isGithubActionEnvironment(): bool
|
||||
{
|
||||
return false !== getenv('GITHUB_ACTIONS');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output an error using the Github annotations format.
|
||||
*
|
||||
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
|
||||
*/
|
||||
public function error(string $message, ?string $file = null, ?int $line = null, ?int $col = null): void
|
||||
{
|
||||
$this->log('error', $message, $file, $line, $col);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a warning using the Github annotations format.
|
||||
*
|
||||
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message
|
||||
*/
|
||||
public function warning(string $message, ?string $file = null, ?int $line = null, ?int $col = null): void
|
||||
{
|
||||
$this->log('warning', $message, $file, $line, $col);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a debug log using the Github annotations format.
|
||||
*
|
||||
* @see https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message
|
||||
*/
|
||||
public function debug(string $message, ?string $file = null, ?int $line = null, ?int $col = null): void
|
||||
{
|
||||
$this->log('debug', $message, $file, $line, $col);
|
||||
}
|
||||
|
||||
private function log(string $type, string $message, ?string $file = null, ?int $line = null, ?int $col = null): void
|
||||
{
|
||||
// Some values must be encoded.
|
||||
$message = strtr($message, self::ESCAPED_DATA);
|
||||
|
||||
if (!$file) {
|
||||
// No file provided, output the message solely:
|
||||
$this->output->writeln(\sprintf('::%s::%s', $type, $message));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->writeln(\sprintf('::%s file=%s,line=%s,col=%s::%s', $type, strtr($file, self::ESCAPED_PROPERTIES), strtr($line ?? 1, self::ESCAPED_PROPERTIES), strtr($col ?? 0, self::ESCAPED_PROPERTIES), $message));
|
||||
}
|
||||
}
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
final class Color
|
||||
{
|
||||
private const COLORS = [
|
||||
'black' => 0,
|
||||
'red' => 1,
|
||||
'green' => 2,
|
||||
'yellow' => 3,
|
||||
'blue' => 4,
|
||||
'magenta' => 5,
|
||||
'cyan' => 6,
|
||||
'white' => 7,
|
||||
'default' => 9,
|
||||
];
|
||||
|
||||
private const BRIGHT_COLORS = [
|
||||
'gray' => 0,
|
||||
'bright-red' => 1,
|
||||
'bright-green' => 2,
|
||||
'bright-yellow' => 3,
|
||||
'bright-blue' => 4,
|
||||
'bright-magenta' => 5,
|
||||
'bright-cyan' => 6,
|
||||
'bright-white' => 7,
|
||||
];
|
||||
|
||||
private const AVAILABLE_OPTIONS = [
|
||||
'bold' => ['set' => 1, 'unset' => 22],
|
||||
'underscore' => ['set' => 4, 'unset' => 24],
|
||||
'blink' => ['set' => 5, 'unset' => 25],
|
||||
'reverse' => ['set' => 7, 'unset' => 27],
|
||||
'conceal' => ['set' => 8, 'unset' => 28],
|
||||
];
|
||||
|
||||
private string $foreground;
|
||||
private string $background;
|
||||
private array $options = [];
|
||||
|
||||
public function __construct(string $foreground = '', string $background = '', array $options = [])
|
||||
{
|
||||
$this->foreground = $this->parseColor($foreground);
|
||||
$this->background = $this->parseColor($background, true);
|
||||
|
||||
foreach ($options as $option) {
|
||||
if (!isset(self::AVAILABLE_OPTIONS[$option])) {
|
||||
throw new InvalidArgumentException(\sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::AVAILABLE_OPTIONS))));
|
||||
}
|
||||
|
||||
$this->options[$option] = self::AVAILABLE_OPTIONS[$option];
|
||||
}
|
||||
}
|
||||
|
||||
public function apply(string $text): string
|
||||
{
|
||||
return $this->set().$text.$this->unset();
|
||||
}
|
||||
|
||||
public function set(): string
|
||||
{
|
||||
$setCodes = [];
|
||||
if ('' !== $this->foreground) {
|
||||
$setCodes[] = $this->foreground;
|
||||
}
|
||||
if ('' !== $this->background) {
|
||||
$setCodes[] = $this->background;
|
||||
}
|
||||
foreach ($this->options as $option) {
|
||||
$setCodes[] = $option['set'];
|
||||
}
|
||||
if (0 === \count($setCodes)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \sprintf("\033[%sm", implode(';', $setCodes));
|
||||
}
|
||||
|
||||
public function unset(): string
|
||||
{
|
||||
$unsetCodes = [];
|
||||
if ('' !== $this->foreground) {
|
||||
$unsetCodes[] = 39;
|
||||
}
|
||||
if ('' !== $this->background) {
|
||||
$unsetCodes[] = 49;
|
||||
}
|
||||
foreach ($this->options as $option) {
|
||||
$unsetCodes[] = $option['unset'];
|
||||
}
|
||||
if (0 === \count($unsetCodes)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \sprintf("\033[%sm", implode(';', $unsetCodes));
|
||||
}
|
||||
|
||||
private function parseColor(string $color, bool $background = false): string
|
||||
{
|
||||
if ('' === $color) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ('#' === $color[0]) {
|
||||
return ($background ? '4' : '3').Terminal::getColorMode()->convertFromHexToAnsiColorCode($color);
|
||||
}
|
||||
|
||||
if (isset(self::COLORS[$color])) {
|
||||
return ($background ? '4' : '3').self::COLORS[$color];
|
||||
}
|
||||
|
||||
if (isset(self::BRIGHT_COLORS[$color])) {
|
||||
return ($background ? '10' : '9').self::BRIGHT_COLORS[$color];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(\sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS)))));
|
||||
}
|
||||
}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Completion\Suggestion;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Helper\HelperInterface;
|
||||
use Symfony\Component\Console\Helper\HelperSet;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Base class for all commands.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class Command implements SignalableCommandInterface
|
||||
{
|
||||
// see https://tldp.org/LDP/abs/html/exitcodes.html
|
||||
public const SUCCESS = 0;
|
||||
public const FAILURE = 1;
|
||||
public const INVALID = 2;
|
||||
|
||||
private ?Application $application = null;
|
||||
private ?string $name = null;
|
||||
private ?string $processTitle = null;
|
||||
private array $aliases = [];
|
||||
private InputDefinition $definition;
|
||||
private bool $hidden = false;
|
||||
private string $help = '';
|
||||
private string $description = '';
|
||||
private ?InputDefinition $fullDefinition = null;
|
||||
private bool $ignoreValidationErrors = false;
|
||||
private ?InvokableCommand $code = null;
|
||||
private array $synopsis = [];
|
||||
private array $usages = [];
|
||||
private ?HelperSet $helperSet = null;
|
||||
|
||||
/**
|
||||
* @deprecated since Symfony 7.3, use the #[AsCommand] attribute instead
|
||||
*/
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
trigger_deprecation('symfony/console', '7.3', 'Method "%s()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', __METHOD__);
|
||||
|
||||
if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) {
|
||||
return $attribute[0]->newInstance()->name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since Symfony 7.3, use the #[AsCommand] attribute instead
|
||||
*/
|
||||
public static function getDefaultDescription(): ?string
|
||||
{
|
||||
trigger_deprecation('symfony/console', '7.3', 'Method "%s()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', __METHOD__);
|
||||
|
||||
if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) {
|
||||
return $attribute[0]->newInstance()->description;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $name The name of the command; passing null means it must be set in configure()
|
||||
*
|
||||
* @throws LogicException When the command name is empty
|
||||
*/
|
||||
public function __construct(?string $name = null)
|
||||
{
|
||||
$this->definition = new InputDefinition();
|
||||
|
||||
$attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
|
||||
if (null === $name) {
|
||||
if (self::class !== (new \ReflectionMethod($this, 'getDefaultName'))->class) {
|
||||
trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultName()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', static::class);
|
||||
|
||||
$defaultName = static::getDefaultName();
|
||||
} else {
|
||||
$defaultName = $attribute?->name;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $name && null !== $name = $defaultName) {
|
||||
$aliases = explode('|', $name);
|
||||
|
||||
if ('' === $name = array_shift($aliases)) {
|
||||
$this->setHidden(true);
|
||||
$name = array_shift($aliases);
|
||||
}
|
||||
|
||||
$this->setAliases($aliases);
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$this->setName($name);
|
||||
}
|
||||
|
||||
if ('' === $this->description) {
|
||||
if (self::class !== (new \ReflectionMethod($this, 'getDefaultDescription'))->class) {
|
||||
trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultDescription()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', static::class);
|
||||
|
||||
$defaultDescription = static::getDefaultDescription();
|
||||
} else {
|
||||
$defaultDescription = $attribute?->description;
|
||||
}
|
||||
|
||||
$this->setDescription($defaultDescription ?? '');
|
||||
}
|
||||
|
||||
if ('' === $this->help) {
|
||||
$this->setHelp($attribute?->help ?? '');
|
||||
}
|
||||
|
||||
if (\is_callable($this) && self::class === (new \ReflectionMethod($this, 'execute'))->getDeclaringClass()->name) {
|
||||
$this->code = new InvokableCommand($this, $this(...));
|
||||
}
|
||||
|
||||
$this->configure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignores validation errors.
|
||||
*
|
||||
* This is mainly useful for the help command.
|
||||
*/
|
||||
public function ignoreValidationErrors(): void
|
||||
{
|
||||
$this->ignoreValidationErrors = true;
|
||||
}
|
||||
|
||||
public function setApplication(?Application $application): void
|
||||
{
|
||||
$this->application = $application;
|
||||
if ($application) {
|
||||
$this->setHelperSet($application->getHelperSet());
|
||||
} else {
|
||||
$this->helperSet = null;
|
||||
}
|
||||
|
||||
$this->fullDefinition = null;
|
||||
}
|
||||
|
||||
public function setHelperSet(HelperSet $helperSet): void
|
||||
{
|
||||
$this->helperSet = $helperSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the helper set.
|
||||
*/
|
||||
public function getHelperSet(): ?HelperSet
|
||||
{
|
||||
return $this->helperSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the application instance for this command.
|
||||
*/
|
||||
public function getApplication(): ?Application
|
||||
{
|
||||
return $this->application;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the command is enabled or not in the current environment.
|
||||
*
|
||||
* Override this to check for x or y and return false if the command cannot
|
||||
* run properly under the current conditions.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the current command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the current command.
|
||||
*
|
||||
* This method is not abstract because you can use this class
|
||||
* as a concrete class. In this case, instead of defining the
|
||||
* execute() method, you set the code to execute by passing
|
||||
* a Closure to the setCode() method.
|
||||
*
|
||||
* @return int 0 if everything went fine, or an exit code
|
||||
*
|
||||
* @throws LogicException When this abstract method is not implemented
|
||||
*
|
||||
* @see setCode()
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
throw new LogicException('You must override the execute() method in the concrete command class.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interacts with the user.
|
||||
*
|
||||
* This method is executed before the InputDefinition is validated.
|
||||
* This means that this is the only place where the command can
|
||||
* interactively ask for values of missing required arguments.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function interact(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the command after the input has been bound and before the input
|
||||
* is validated.
|
||||
*
|
||||
* This is mainly useful when a lot of commands extends one main command
|
||||
* where some things need to be initialized based on the input arguments and options.
|
||||
*
|
||||
* @see InputInterface::bind()
|
||||
* @see InputInterface::validate()
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the command.
|
||||
*
|
||||
* The code to execute is either defined directly with the
|
||||
* setCode() method or by overriding the execute() method
|
||||
* in a sub-class.
|
||||
*
|
||||
* @return int The command exit code
|
||||
*
|
||||
* @throws ExceptionInterface When input binding fails. Bypass this by calling {@link ignoreValidationErrors()}.
|
||||
*
|
||||
* @see setCode()
|
||||
* @see execute()
|
||||
*/
|
||||
public function run(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// add the application arguments and options
|
||||
$this->mergeApplicationDefinition();
|
||||
|
||||
// bind the input against the command specific arguments/options
|
||||
try {
|
||||
$input->bind($this->getDefinition());
|
||||
} catch (ExceptionInterface $e) {
|
||||
if (!$this->ignoreValidationErrors) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$this->initialize($input, $output);
|
||||
|
||||
if (null !== $this->processTitle) {
|
||||
if (\function_exists('cli_set_process_title')) {
|
||||
if (!@cli_set_process_title($this->processTitle)) {
|
||||
if ('Darwin' === \PHP_OS) {
|
||||
$output->writeln('<comment>Running "cli_set_process_title" as an unprivileged user is not supported on MacOS.</comment>', OutputInterface::VERBOSITY_VERY_VERBOSE);
|
||||
} else {
|
||||
cli_set_process_title($this->processTitle);
|
||||
}
|
||||
}
|
||||
} elseif (\function_exists('setproctitle')) {
|
||||
setproctitle($this->processTitle);
|
||||
} elseif (OutputInterface::VERBOSITY_VERY_VERBOSE === $output->getVerbosity()) {
|
||||
$output->writeln('<comment>Install the proctitle PECL to be able to change the process title.</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($input->isInteractive()) {
|
||||
$this->interact($input, $output);
|
||||
}
|
||||
|
||||
// The command name argument is often omitted when a command is executed directly with its run() method.
|
||||
// It would fail the validation if we didn't make sure the command argument is present,
|
||||
// since it's required by the application.
|
||||
if ($input->hasArgument('command') && null === $input->getArgument('command')) {
|
||||
$input->setArgument('command', $this->getName());
|
||||
}
|
||||
|
||||
$input->validate();
|
||||
|
||||
if ($this->code) {
|
||||
return ($this->code)($input, $output);
|
||||
}
|
||||
|
||||
return $this->execute($input, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies suggestions when resolving possible completion options for input (e.g. option or argument).
|
||||
*/
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
$definition = $this->getDefinition();
|
||||
if (CompletionInput::TYPE_OPTION_VALUE === $input->getCompletionType() && $definition->hasOption($input->getCompletionName())) {
|
||||
$definition->getOption($input->getCompletionName())->complete($input, $suggestions);
|
||||
} elseif (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType() && $definition->hasArgument($input->getCompletionName())) {
|
||||
$definition->getArgument($input->getCompletionName())->complete($input, $suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the code to execute when running this command.
|
||||
*
|
||||
* If this method is used, it overrides the code defined
|
||||
* in the execute() method.
|
||||
*
|
||||
* @param callable $code A callable(InputInterface $input, OutputInterface $output)
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*
|
||||
* @see execute()
|
||||
*/
|
||||
public function setCode(callable $code): static
|
||||
{
|
||||
$this->code = new InvokableCommand($this, $code);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the application definition with the command definition.
|
||||
*
|
||||
* This method is not part of public API and should not be used directly.
|
||||
*
|
||||
* @param bool $mergeArgs Whether to merge or not the Application definition arguments to Command definition arguments
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function mergeApplicationDefinition(bool $mergeArgs = true): void
|
||||
{
|
||||
if (null === $this->application) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fullDefinition = new InputDefinition();
|
||||
$this->fullDefinition->setOptions($this->definition->getOptions());
|
||||
$this->fullDefinition->addOptions($this->application->getDefinition()->getOptions());
|
||||
|
||||
if ($mergeArgs) {
|
||||
$this->fullDefinition->setArguments($this->application->getDefinition()->getArguments());
|
||||
$this->fullDefinition->addArguments($this->definition->getArguments());
|
||||
} else {
|
||||
$this->fullDefinition->setArguments($this->definition->getArguments());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an array of argument and option instances.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setDefinition(array|InputDefinition $definition): static
|
||||
{
|
||||
if ($definition instanceof InputDefinition) {
|
||||
$this->definition = $definition;
|
||||
} else {
|
||||
$this->definition->setDefinition($definition);
|
||||
}
|
||||
|
||||
$this->fullDefinition = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the InputDefinition attached to this Command.
|
||||
*/
|
||||
public function getDefinition(): InputDefinition
|
||||
{
|
||||
return $this->fullDefinition ?? $this->getNativeDefinition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the InputDefinition to be used to create representations of this Command.
|
||||
*
|
||||
* Can be overridden to provide the original command representation when it would otherwise
|
||||
* be changed by merging with the application InputDefinition.
|
||||
*
|
||||
* This method is not part of public API and should not be used directly.
|
||||
*/
|
||||
public function getNativeDefinition(): InputDefinition
|
||||
{
|
||||
$definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class));
|
||||
|
||||
if ($this->code && !$definition->getArguments() && !$definition->getOptions()) {
|
||||
$this->code->configure($definition);
|
||||
}
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an argument.
|
||||
*
|
||||
* @param $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL
|
||||
* @param $default The default value (for InputArgument::OPTIONAL mode only)
|
||||
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws InvalidArgumentException When argument mode is not valid
|
||||
*/
|
||||
public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->definition->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));
|
||||
$this->fullDefinition?->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an option.
|
||||
*
|
||||
* @param $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
|
||||
* @param $mode The option mode: One of the InputOption::VALUE_* constants
|
||||
* @param $default The default value (must be null for InputOption::VALUE_NONE)
|
||||
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws InvalidArgumentException If option mode is invalid or incompatible
|
||||
*/
|
||||
public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->definition->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));
|
||||
$this->fullDefinition?->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the command.
|
||||
*
|
||||
* This method can set both the namespace and the name if
|
||||
* you separate them by a colon (:)
|
||||
*
|
||||
* $command->setName('foo:bar');
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws InvalidArgumentException When the name is invalid
|
||||
*/
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->validateName($name);
|
||||
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the process title of the command.
|
||||
*
|
||||
* This feature should be used only when creating a long process command,
|
||||
* like a daemon.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setProcessTitle(string $title): static
|
||||
{
|
||||
$this->processTitle = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the command name.
|
||||
*/
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $hidden Whether or not the command should be hidden from the list of commands
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setHidden(bool $hidden = true): static
|
||||
{
|
||||
$this->hidden = $hidden;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool whether the command should be publicly shown or not
|
||||
*/
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the description for the command.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setDescription(string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the description for the command.
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the help for the command.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setHelp(string $help): static
|
||||
{
|
||||
$this->help = $help;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the help for the command.
|
||||
*/
|
||||
public function getHelp(): string
|
||||
{
|
||||
return $this->help;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the processed help for the command replacing the %command.name% and
|
||||
* %command.full_name% patterns with the real values dynamically.
|
||||
*/
|
||||
public function getProcessedHelp(): string
|
||||
{
|
||||
$name = $this->name;
|
||||
$isSingleCommand = $this->application?->isSingleCommand();
|
||||
|
||||
$placeholders = [
|
||||
'%command.name%',
|
||||
'%command.full_name%',
|
||||
];
|
||||
$replacements = [
|
||||
$name,
|
||||
$isSingleCommand ? $_SERVER['PHP_SELF'] : $_SERVER['PHP_SELF'].' '.$name,
|
||||
];
|
||||
|
||||
return str_replace($placeholders, $replacements, $this->getHelp() ?: $this->getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the aliases for the command.
|
||||
*
|
||||
* @param string[] $aliases An array of aliases for the command
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws InvalidArgumentException When an alias is invalid
|
||||
*/
|
||||
public function setAliases(iterable $aliases): static
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$this->validateName($alias);
|
||||
$list[] = $alias;
|
||||
}
|
||||
|
||||
$this->aliases = \is_array($aliases) ? $aliases : $list;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the aliases for the command.
|
||||
*/
|
||||
public function getAliases(): array
|
||||
{
|
||||
return $this->aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the synopsis for the command.
|
||||
*
|
||||
* @param bool $short Whether to show the short version of the synopsis (with options folded) or not
|
||||
*/
|
||||
public function getSynopsis(bool $short = false): string
|
||||
{
|
||||
$key = $short ? 'short' : 'long';
|
||||
|
||||
if (!isset($this->synopsis[$key])) {
|
||||
$this->synopsis[$key] = trim(\sprintf('%s %s', $this->name, $this->definition->getSynopsis($short)));
|
||||
}
|
||||
|
||||
return $this->synopsis[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a command usage example, it'll be prefixed with the command name.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addUsage(string $usage): static
|
||||
{
|
||||
if (!str_starts_with($usage, $this->name)) {
|
||||
$usage = \sprintf('%s %s', $this->name, $usage);
|
||||
}
|
||||
|
||||
$this->usages[] = $usage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns alternative usages of the command.
|
||||
*/
|
||||
public function getUsages(): array
|
||||
{
|
||||
return $this->usages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a helper instance by name.
|
||||
*
|
||||
* @throws LogicException if no HelperSet is defined
|
||||
* @throws InvalidArgumentException if the helper is not defined
|
||||
*/
|
||||
public function getHelper(string $name): HelperInterface
|
||||
{
|
||||
if (null === $this->helperSet) {
|
||||
throw new LogicException(\sprintf('Cannot retrieve helper "%s" because there is no HelperSet defined. Did you forget to add your command to the application or to set the application on the command using the setApplication() method? You can also set the HelperSet directly using the setHelperSet() method.', $name));
|
||||
}
|
||||
|
||||
return $this->helperSet->get($name);
|
||||
}
|
||||
|
||||
public function getSubscribedSignals(): array
|
||||
{
|
||||
return $this->code?->getSubscribedSignals() ?? [];
|
||||
}
|
||||
|
||||
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
|
||||
{
|
||||
return $this->code?->handleSignal($signal, $previousExitCode) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a command name.
|
||||
*
|
||||
* It must be non-empty and parts can optionally be separated by ":".
|
||||
*
|
||||
* @throws InvalidArgumentException When the name is invalid
|
||||
*/
|
||||
private function validateName(string $name): void
|
||||
{
|
||||
if (!preg_match('/^[^\:]++(\:[^\:]++)*$/', $name)) {
|
||||
throw new InvalidArgumentException(\sprintf('Command name "%s" is invalid.', $name));
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Completion\Output\BashCompletionOutput;
|
||||
use Symfony\Component\Console\Completion\Output\CompletionOutputInterface;
|
||||
use Symfony\Component\Console\Completion\Output\FishCompletionOutput;
|
||||
use Symfony\Component\Console\Completion\Output\ZshCompletionOutput;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Responsible for providing the values to the shell completion.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
#[AsCommand(name: '|_complete', description: 'Internal command to provide shell completion suggestions')]
|
||||
final class CompleteCommand extends Command
|
||||
{
|
||||
public const COMPLETION_API_VERSION = '1';
|
||||
|
||||
private array $completionOutputs;
|
||||
private bool $isDebug = false;
|
||||
|
||||
/**
|
||||
* @param array<string, class-string<CompletionOutputInterface>> $completionOutputs A list of additional completion outputs, with shell name as key and FQCN as value
|
||||
*/
|
||||
public function __construct(array $completionOutputs = [])
|
||||
{
|
||||
// must be set before the parent constructor, as the property value is used in configure()
|
||||
$this->completionOutputs = $completionOutputs + [
|
||||
'bash' => BashCompletionOutput::class,
|
||||
'fish' => FishCompletionOutput::class,
|
||||
'zsh' => ZshCompletionOutput::class,
|
||||
];
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('shell', 's', InputOption::VALUE_REQUIRED, 'The shell type ("'.implode('", "', array_keys($this->completionOutputs)).'")')
|
||||
->addOption('input', 'i', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'An array of input tokens (e.g. COMP_WORDS or argv)')
|
||||
->addOption('current', 'c', InputOption::VALUE_REQUIRED, 'The index of the "input" array that the cursor is in (e.g. COMP_CWORD)')
|
||||
->addOption('api-version', 'a', InputOption::VALUE_REQUIRED, 'The API version of the completion script')
|
||||
->addOption('symfony', 'S', InputOption::VALUE_REQUIRED, 'deprecated')
|
||||
;
|
||||
}
|
||||
|
||||
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$this->isDebug = filter_var(getenv('SYMFONY_COMPLETION_DEBUG'), \FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
// "symfony" must be kept for compat with the shell scripts generated by Symfony Console 5.4 - 6.1
|
||||
$version = $input->getOption('symfony') ? '1' : $input->getOption('api-version');
|
||||
if ($version && version_compare($version, self::COMPLETION_API_VERSION, '<')) {
|
||||
$message = \sprintf('Completion script version is not supported ("%s" given, ">=%s" required).', $version, self::COMPLETION_API_VERSION);
|
||||
$this->log($message);
|
||||
|
||||
$output->writeln($message.' Install the Symfony completion script again by using the "completion" command.');
|
||||
|
||||
return 126;
|
||||
}
|
||||
|
||||
$shell = $input->getOption('shell');
|
||||
if (!$shell) {
|
||||
throw new \RuntimeException('The "--shell" option must be set.');
|
||||
}
|
||||
|
||||
if (!$completionOutput = $this->completionOutputs[$shell] ?? false) {
|
||||
throw new \RuntimeException(\sprintf('Shell completion is not supported for your shell: "%s" (supported: "%s").', $shell, implode('", "', array_keys($this->completionOutputs))));
|
||||
}
|
||||
|
||||
$completionInput = $this->createCompletionInput($input);
|
||||
$suggestions = new CompletionSuggestions();
|
||||
|
||||
$this->log([
|
||||
'',
|
||||
'<comment>'.date('Y-m-d H:i:s').'</>',
|
||||
'<info>Input:</> <comment>("|" indicates the cursor position)</>',
|
||||
' '.$completionInput,
|
||||
'<info>Command:</>',
|
||||
' '.implode(' ', $_SERVER['argv']),
|
||||
'<info>Messages:</>',
|
||||
]);
|
||||
|
||||
$command = $this->findCommand($completionInput);
|
||||
if (null === $command) {
|
||||
$this->log(' No command found, completing using the Application class.');
|
||||
|
||||
$this->getApplication()->complete($completionInput, $suggestions);
|
||||
} elseif (
|
||||
$completionInput->mustSuggestArgumentValuesFor('command')
|
||||
&& $command->getName() !== $completionInput->getCompletionValue()
|
||||
&& !\in_array($completionInput->getCompletionValue(), $command->getAliases(), true)
|
||||
) {
|
||||
$this->log(' No command found, completing using the Application class.');
|
||||
|
||||
// expand shortcut names ("cache:cl<TAB>") into their full name ("cache:clear")
|
||||
$suggestions->suggestValues(array_filter(array_merge([$command->getName()], $command->getAliases())));
|
||||
} else {
|
||||
$command->mergeApplicationDefinition();
|
||||
$completionInput->bind($command->getDefinition());
|
||||
|
||||
if (CompletionInput::TYPE_OPTION_NAME === $completionInput->getCompletionType()) {
|
||||
$this->log(' Completing option names for the <comment>'.($command instanceof LazyCommand ? $command->getCommand() : $command)::class.'</> command.');
|
||||
|
||||
$suggestions->suggestOptions($command->getDefinition()->getOptions());
|
||||
} else {
|
||||
$this->log([
|
||||
' Completing using the <comment>'.($command instanceof LazyCommand ? $command->getCommand() : $command)::class.'</> class.',
|
||||
' Completing <comment>'.$completionInput->getCompletionType().'</> for <comment>'.$completionInput->getCompletionName().'</>',
|
||||
]);
|
||||
if (null !== $compval = $completionInput->getCompletionValue()) {
|
||||
$this->log(' Current value: <comment>'.$compval.'</>');
|
||||
}
|
||||
|
||||
$command->complete($completionInput, $suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var CompletionOutputInterface $completionOutput */
|
||||
$completionOutput = new $completionOutput();
|
||||
|
||||
$this->log('<info>Suggestions:</>');
|
||||
if ($options = $suggestions->getOptionSuggestions()) {
|
||||
$this->log(' --'.implode(' --', array_map(fn ($o) => $o->getName(), $options)));
|
||||
} elseif ($values = $suggestions->getValueSuggestions()) {
|
||||
$this->log(' '.implode(' ', $values));
|
||||
} else {
|
||||
$this->log(' <comment>No suggestions were provided</>');
|
||||
}
|
||||
|
||||
$completionOutput->write($suggestions, $output);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log([
|
||||
'<error>Error!</error>',
|
||||
(string) $e,
|
||||
]);
|
||||
|
||||
if ($output->isDebug()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createCompletionInput(InputInterface $input): CompletionInput
|
||||
{
|
||||
$currentIndex = $input->getOption('current');
|
||||
if (!$currentIndex || !ctype_digit($currentIndex)) {
|
||||
throw new \RuntimeException('The "--current" option must be set and it must be an integer.');
|
||||
}
|
||||
|
||||
$completionInput = CompletionInput::fromTokens($input->getOption('input'), (int) $currentIndex);
|
||||
|
||||
try {
|
||||
$completionInput->bind($this->getApplication()->getDefinition());
|
||||
} catch (ExceptionInterface) {
|
||||
}
|
||||
|
||||
return $completionInput;
|
||||
}
|
||||
|
||||
private function findCommand(CompletionInput $completionInput): ?Command
|
||||
{
|
||||
try {
|
||||
$inputName = $completionInput->getFirstArgument();
|
||||
if (null === $inputName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getApplication()->find($inputName);
|
||||
} catch (CommandNotFoundException) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function log($messages): void
|
||||
{
|
||||
if (!$this->isDebug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$commandName = basename($_SERVER['argv'][0]);
|
||||
file_put_contents(sys_get_temp_dir().'/sf_'.$commandName.'.log', implode(\PHP_EOL, (array) $messages).\PHP_EOL, \FILE_APPEND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Dumps the completion script for the current shell.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
#[AsCommand(name: 'completion', description: 'Dump the shell completion script')]
|
||||
final class DumpCompletionCommand extends Command
|
||||
{
|
||||
private array $supportedShells;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$fullCommand = $_SERVER['PHP_SELF'];
|
||||
$commandName = basename($fullCommand);
|
||||
$fullCommand = @realpath($fullCommand) ?: $fullCommand;
|
||||
|
||||
$shell = self::guessShell();
|
||||
[$rcFile, $completionFile] = match ($shell) {
|
||||
'fish' => ['~/.config/fish/config.fish', "/etc/fish/completions/$commandName.fish"],
|
||||
'zsh' => ['~/.zshrc', '$fpath[1]/_'.$commandName],
|
||||
default => ['~/.bashrc', "/etc/bash_completion.d/$commandName"],
|
||||
};
|
||||
|
||||
$supportedShells = implode(', ', $this->getSupportedShells());
|
||||
|
||||
$this
|
||||
->setHelp(<<<EOH
|
||||
The <info>%command.name%</> command dumps the shell completion script required
|
||||
to use shell autocompletion (currently, {$supportedShells} completion are supported).
|
||||
|
||||
<comment>Static installation
|
||||
-------------------</>
|
||||
|
||||
Dump the script to a global completion file and restart your shell:
|
||||
|
||||
<info>%command.full_name% {$shell} | sudo tee {$completionFile}</>
|
||||
|
||||
Or dump the script to a local file and source it:
|
||||
|
||||
<info>%command.full_name% {$shell} > completion.sh</>
|
||||
|
||||
<comment># source the file whenever you use the project</>
|
||||
<info>source completion.sh</>
|
||||
|
||||
<comment># or add this line at the end of your "{$rcFile}" file:</>
|
||||
<info>source /path/to/completion.sh</>
|
||||
|
||||
<comment>Dynamic installation
|
||||
--------------------</>
|
||||
|
||||
Add this to the end of your shell configuration file (e.g. <info>"{$rcFile}"</>):
|
||||
|
||||
<info>eval "$({$fullCommand} completion {$shell})"</>
|
||||
EOH
|
||||
)
|
||||
->addArgument('shell', InputArgument::OPTIONAL, 'The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given', null, $this->getSupportedShells(...))
|
||||
->addOption('debug', null, InputOption::VALUE_NONE, 'Tail the completion debug log')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$commandName = basename($_SERVER['argv'][0]);
|
||||
|
||||
if ($input->getOption('debug')) {
|
||||
$this->tailDebugLog($commandName, $output);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shell = $input->getArgument('shell') ?? self::guessShell();
|
||||
$completionFile = __DIR__.'/../Resources/completion.'.$shell;
|
||||
if (!file_exists($completionFile)) {
|
||||
$supportedShells = $this->getSupportedShells();
|
||||
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
if ($shell) {
|
||||
$output->writeln(\sprintf('<error>Detected shell "%s", which is not supported by Symfony shell completion (supported shells: "%s").</>', $shell, implode('", "', $supportedShells)));
|
||||
} else {
|
||||
$output->writeln(\sprintf('<error>Shell not detected, Symfony shell completion only supports "%s").</>', implode('", "', $supportedShells)));
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$output->write(str_replace(['{{ COMMAND_NAME }}', '{{ VERSION }}'], [$commandName, CompleteCommand::COMPLETION_API_VERSION], file_get_contents($completionFile)));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function guessShell(): string
|
||||
{
|
||||
return basename($_SERVER['SHELL'] ?? '');
|
||||
}
|
||||
|
||||
private function tailDebugLog(string $commandName, OutputInterface $output): void
|
||||
{
|
||||
$debugFile = sys_get_temp_dir().'/sf_'.$commandName.'.log';
|
||||
if (!file_exists($debugFile)) {
|
||||
touch($debugFile);
|
||||
}
|
||||
$process = new Process(['tail', '-f', $debugFile], null, null, null, 0);
|
||||
$process->run(function (string $type, string $line) use ($output): void {
|
||||
$output->write($line);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getSupportedShells(): array
|
||||
{
|
||||
if (isset($this->supportedShells)) {
|
||||
return $this->supportedShells;
|
||||
}
|
||||
|
||||
$shells = [];
|
||||
|
||||
foreach (new \DirectoryIterator(__DIR__.'/../Resources/') as $file) {
|
||||
if (str_starts_with($file->getBasename(), 'completion.') && $file->isFile()) {
|
||||
$shells[] = $file->getExtension();
|
||||
}
|
||||
}
|
||||
sort($shells);
|
||||
|
||||
return $this->supportedShells = $shells;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Descriptor\ApplicationDescription;
|
||||
use Symfony\Component\Console\Helper\DescriptorHelper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* HelpCommand displays the help for a given command.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class HelpCommand extends Command
|
||||
{
|
||||
private Command $command;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->ignoreValidationErrors();
|
||||
|
||||
$this
|
||||
->setName('help')
|
||||
->setDefinition([
|
||||
new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name', 'help', fn () => array_keys((new ApplicationDescription($this->getApplication()))->getCommands())),
|
||||
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt', fn () => (new DescriptorHelper())->getFormats()),
|
||||
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command help'),
|
||||
])
|
||||
->setDescription('Display help for a command')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command displays help for a given command:
|
||||
|
||||
<info>%command.full_name% list</info>
|
||||
|
||||
You can also output the help in other formats by using the <comment>--format</comment> option:
|
||||
|
||||
<info>%command.full_name% --format=xml list</info>
|
||||
|
||||
To display the list of available commands, please use the <info>list</info> command.
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
public function setCommand(Command $command): void
|
||||
{
|
||||
$this->command = $command;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->command ??= $this->getApplication()->find($input->getArgument('command_name'));
|
||||
|
||||
$helper = new DescriptorHelper();
|
||||
$helper->describe($output, $this->command, [
|
||||
'format' => $input->getOption('format'),
|
||||
'raw_text' => $input->getOption('raw'),
|
||||
]);
|
||||
|
||||
unset($this->command);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Represents an invokable command.
|
||||
*
|
||||
* @author Yonel Ceruto <open@yceruto.dev>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class InvokableCommand implements SignalableCommandInterface
|
||||
{
|
||||
private readonly \Closure $code;
|
||||
private readonly ?SignalableCommandInterface $signalableCommand;
|
||||
private readonly \ReflectionFunction $reflection;
|
||||
private bool $triggerDeprecations = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly Command $command,
|
||||
callable $code,
|
||||
) {
|
||||
$this->code = $this->getClosure($code);
|
||||
$this->signalableCommand = $code instanceof SignalableCommandInterface ? $code : null;
|
||||
$this->reflection = new \ReflectionFunction($this->code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes a callable with parameters generated from the input interface.
|
||||
*/
|
||||
public function __invoke(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$statusCode = ($this->code)(...$this->getParameters($input, $output));
|
||||
|
||||
if (!\is_int($statusCode)) {
|
||||
if ($this->triggerDeprecations) {
|
||||
trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in Symfony 8.0.', $this->command->getName()));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw new \TypeError(\sprintf('The command "%s" must return an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode)));
|
||||
}
|
||||
|
||||
return $statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the input definition from an invokable-defined function.
|
||||
*
|
||||
* Processes the parameters of the reflection function to extract and
|
||||
* add arguments or options to the provided input definition.
|
||||
*/
|
||||
public function configure(InputDefinition $definition): void
|
||||
{
|
||||
foreach ($this->reflection->getParameters() as $parameter) {
|
||||
if ($argument = Argument::tryFrom($parameter)) {
|
||||
$definition->addArgument($argument->toInputArgument());
|
||||
} elseif ($option = Option::tryFrom($parameter)) {
|
||||
$definition->addOption($option->toInputOption());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getClosure(callable $code): \Closure
|
||||
{
|
||||
if (!$code instanceof \Closure) {
|
||||
return $code(...);
|
||||
}
|
||||
|
||||
$this->triggerDeprecations = true;
|
||||
|
||||
if (null !== (new \ReflectionFunction($code))->getClosureThis()) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
set_error_handler(static function () {});
|
||||
try {
|
||||
if ($c = \Closure::bind($code, $this->command)) {
|
||||
$code = $c;
|
||||
}
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function getParameters(InputInterface $input, OutputInterface $output): array
|
||||
{
|
||||
$parameters = [];
|
||||
foreach ($this->reflection->getParameters() as $parameter) {
|
||||
if ($argument = Argument::tryFrom($parameter)) {
|
||||
$parameters[] = $argument->resolveValue($input);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($option = Option::tryFrom($parameter)) {
|
||||
$parameters[] = $option->resolveValue($input);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $parameter->getType();
|
||||
|
||||
if (!$type instanceof \ReflectionNamedType) {
|
||||
if ($this->triggerDeprecations) {
|
||||
trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in Symfony 8.0.', $parameter->getName()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported.', $parameter->getName()));
|
||||
}
|
||||
|
||||
$parameters[] = match ($type->getName()) {
|
||||
InputInterface::class => $input,
|
||||
OutputInterface::class => $output,
|
||||
SymfonyStyle::class => new SymfonyStyle($input, $output),
|
||||
Application::class => $this->command->getApplication(),
|
||||
default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())),
|
||||
};
|
||||
}
|
||||
|
||||
return $parameters ?: [$input, $output];
|
||||
}
|
||||
|
||||
public function getSubscribedSignals(): array
|
||||
{
|
||||
return $this->signalableCommand?->getSubscribedSignals() ?? [];
|
||||
}
|
||||
|
||||
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
|
||||
{
|
||||
return $this->signalableCommand?->handleSignal($signal, $previousExitCode) ?? false;
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Completion\Suggestion;
|
||||
use Symfony\Component\Console\Helper\HelperInterface;
|
||||
use Symfony\Component\Console\Helper\HelperSet;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*/
|
||||
final class LazyCommand extends Command
|
||||
{
|
||||
private \Closure|Command $command;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
array $aliases,
|
||||
string $description,
|
||||
bool $isHidden,
|
||||
\Closure $commandFactory,
|
||||
private ?bool $isEnabled = true,
|
||||
) {
|
||||
$this->setName($name)
|
||||
->setAliases($aliases)
|
||||
->setHidden($isHidden)
|
||||
->setDescription($description);
|
||||
|
||||
$this->command = $commandFactory;
|
||||
}
|
||||
|
||||
public function ignoreValidationErrors(): void
|
||||
{
|
||||
$this->getCommand()->ignoreValidationErrors();
|
||||
}
|
||||
|
||||
public function setApplication(?Application $application): void
|
||||
{
|
||||
if ($this->command instanceof parent) {
|
||||
$this->command->setApplication($application);
|
||||
}
|
||||
|
||||
parent::setApplication($application);
|
||||
}
|
||||
|
||||
public function setHelperSet(HelperSet $helperSet): void
|
||||
{
|
||||
if ($this->command instanceof parent) {
|
||||
$this->command->setHelperSet($helperSet);
|
||||
}
|
||||
|
||||
parent::setHelperSet($helperSet);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->isEnabled ?? $this->getCommand()->isEnabled();
|
||||
}
|
||||
|
||||
public function run(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->getCommand()->run($input, $output);
|
||||
}
|
||||
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
$this->getCommand()->complete($input, $suggestions);
|
||||
}
|
||||
|
||||
public function setCode(callable $code): static
|
||||
{
|
||||
$this->getCommand()->setCode($code);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function mergeApplicationDefinition(bool $mergeArgs = true): void
|
||||
{
|
||||
$this->getCommand()->mergeApplicationDefinition($mergeArgs);
|
||||
}
|
||||
|
||||
public function setDefinition(array|InputDefinition $definition): static
|
||||
{
|
||||
$this->getCommand()->setDefinition($definition);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDefinition(): InputDefinition
|
||||
{
|
||||
return $this->getCommand()->getDefinition();
|
||||
}
|
||||
|
||||
public function getNativeDefinition(): InputDefinition
|
||||
{
|
||||
return $this->getCommand()->getNativeDefinition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*/
|
||||
public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->getCommand()->addArgument($name, $mode, $description, $default, $suggestedValues);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
|
||||
*/
|
||||
public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->getCommand()->addOption($name, $shortcut, $mode, $description, $default, $suggestedValues);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setProcessTitle(string $title): static
|
||||
{
|
||||
$this->getCommand()->setProcessTitle($title);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setHelp(string $help): static
|
||||
{
|
||||
$this->getCommand()->setHelp($help);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHelp(): string
|
||||
{
|
||||
return $this->getCommand()->getHelp();
|
||||
}
|
||||
|
||||
public function getProcessedHelp(): string
|
||||
{
|
||||
return $this->getCommand()->getProcessedHelp();
|
||||
}
|
||||
|
||||
public function getSynopsis(bool $short = false): string
|
||||
{
|
||||
return $this->getCommand()->getSynopsis($short);
|
||||
}
|
||||
|
||||
public function addUsage(string $usage): static
|
||||
{
|
||||
$this->getCommand()->addUsage($usage);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsages(): array
|
||||
{
|
||||
return $this->getCommand()->getUsages();
|
||||
}
|
||||
|
||||
public function getHelper(string $name): HelperInterface
|
||||
{
|
||||
return $this->getCommand()->getHelper($name);
|
||||
}
|
||||
|
||||
public function getCommand(): parent
|
||||
{
|
||||
if (!$this->command instanceof \Closure) {
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
$command = $this->command = ($this->command)();
|
||||
$command->setApplication($this->getApplication());
|
||||
|
||||
if (null !== $this->getHelperSet()) {
|
||||
$command->setHelperSet($this->getHelperSet());
|
||||
}
|
||||
|
||||
$command->setName($this->getName())
|
||||
->setAliases($this->getAliases())
|
||||
->setHidden($this->isHidden())
|
||||
->setDescription($this->getDescription());
|
||||
|
||||
// Will throw if the command is not correctly initialized.
|
||||
$command->getDefinition();
|
||||
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Descriptor\ApplicationDescription;
|
||||
use Symfony\Component\Console\Helper\DescriptorHelper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* ListCommand displays the list of all available commands for the application.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class ListCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('list')
|
||||
->setDefinition([
|
||||
new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name', null, fn () => array_keys((new ApplicationDescription($this->getApplication()))->getNamespaces())),
|
||||
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'),
|
||||
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt', fn () => (new DescriptorHelper())->getFormats()),
|
||||
new InputOption('short', null, InputOption::VALUE_NONE, 'To skip describing commands\' arguments'),
|
||||
])
|
||||
->setDescription('List commands')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command lists all commands:
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can also display the commands for a specific namespace:
|
||||
|
||||
<info>%command.full_name% test</info>
|
||||
|
||||
You can also output the information in other formats by using the <comment>--format</comment> option:
|
||||
|
||||
<info>%command.full_name% --format=xml</info>
|
||||
|
||||
It's also possible to get raw list of commands (useful for embedding command runner):
|
||||
|
||||
<info>%command.full_name% --raw</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$helper = new DescriptorHelper();
|
||||
$helper->describe($output, $this->getApplication(), [
|
||||
'format' => $input->getOption('format'),
|
||||
'raw_text' => $input->getOption('raw'),
|
||||
'namespace' => $input->getArgument('namespace'),
|
||||
'short' => $input->getOption('short'),
|
||||
]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Lock\Store\FlockStore;
|
||||
use Symfony\Component\Lock\Store\SemaphoreStore;
|
||||
|
||||
/**
|
||||
* Basic lock feature for commands.
|
||||
*
|
||||
* @author Geoffrey Brier <geoffrey.brier@gmail.com>
|
||||
*/
|
||||
trait LockableTrait
|
||||
{
|
||||
private ?LockInterface $lock = null;
|
||||
|
||||
private ?LockFactory $lockFactory = null;
|
||||
|
||||
/**
|
||||
* Locks a command.
|
||||
*/
|
||||
private function lock(?string $name = null, bool $blocking = false): bool
|
||||
{
|
||||
if (!class_exists(SemaphoreStore::class)) {
|
||||
throw new LogicException('To enable the locking feature you must install the symfony/lock component. Try running "composer require symfony/lock".');
|
||||
}
|
||||
|
||||
if (null !== $this->lock) {
|
||||
throw new LogicException('A lock is already in place.');
|
||||
}
|
||||
|
||||
if (null === $this->lockFactory) {
|
||||
if (SemaphoreStore::isSupported()) {
|
||||
$store = new SemaphoreStore();
|
||||
} else {
|
||||
$store = new FlockStore();
|
||||
}
|
||||
|
||||
$this->lockFactory = new LockFactory($store);
|
||||
}
|
||||
|
||||
if (!$name) {
|
||||
if ($this instanceof Command) {
|
||||
$name = $this->getName();
|
||||
} elseif ($attribute = (new \ReflectionClass($this::class))->getAttributes(AsCommand::class)) {
|
||||
$name = $attribute[0]->newInstance()->name;
|
||||
} else {
|
||||
throw new LogicException(\sprintf('Lock name missing: provide it via "%s()", #[AsCommand] attribute, or by extending Command class.', __METHOD__));
|
||||
}
|
||||
}
|
||||
|
||||
$this->lock = $this->lockFactory->createLock($name);
|
||||
if (!$this->lock->acquire($blocking)) {
|
||||
$this->lock = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the command lock if there is one.
|
||||
*/
|
||||
private function release(): void
|
||||
{
|
||||
if ($this->lock) {
|
||||
$this->lock->release();
|
||||
$this->lock = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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\Command;
|
||||
|
||||
/**
|
||||
* Interface for command reacting to signal.
|
||||
*
|
||||
* @author Grégoire Pineau <lyrixx@lyrix.info>
|
||||
*/
|
||||
interface SignalableCommandInterface
|
||||
{
|
||||
/**
|
||||
* Returns the list of signals to subscribe.
|
||||
*/
|
||||
public function getSubscribedSignals(): array;
|
||||
|
||||
/**
|
||||
* The method will be called when the application is signaled.
|
||||
*
|
||||
* @return int|false The exit code to return or false to continue the normal execution
|
||||
*/
|
||||
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false;
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Helper\HelperInterface;
|
||||
use Symfony\Component\Console\Helper\HelperSet;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Stopwatch\Stopwatch;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @author Jules Pietri <jules@heahprod.com>
|
||||
*/
|
||||
final class TraceableCommand extends Command
|
||||
{
|
||||
public readonly Command $command;
|
||||
public int $exitCode;
|
||||
public ?int $interruptedBySignal = null;
|
||||
public bool $ignoreValidation;
|
||||
public bool $isInteractive = false;
|
||||
public string $duration = 'n/a';
|
||||
public string $maxMemoryUsage = 'n/a';
|
||||
public InputInterface $input;
|
||||
public OutputInterface $output;
|
||||
/** @var array<string, mixed> */
|
||||
public array $arguments;
|
||||
/** @var array<string, mixed> */
|
||||
public array $options;
|
||||
/** @var array<string, mixed> */
|
||||
public array $interactiveInputs = [];
|
||||
public array $handledSignals = [];
|
||||
public ?array $invokableCommandInfo = null;
|
||||
|
||||
public function __construct(
|
||||
Command $command,
|
||||
private readonly Stopwatch $stopwatch,
|
||||
) {
|
||||
if ($command instanceof LazyCommand) {
|
||||
$command = $command->getCommand();
|
||||
}
|
||||
|
||||
$this->command = $command;
|
||||
|
||||
// prevent call to self::getDefaultDescription()
|
||||
$this->setDescription($command->getDescription());
|
||||
|
||||
parent::__construct($command->getName());
|
||||
|
||||
// init below enables calling {@see parent::run()}
|
||||
[$code, $processTitle, $ignoreValidationErrors] = \Closure::bind(function () {
|
||||
return [$this->code, $this->processTitle, $this->ignoreValidationErrors];
|
||||
}, $command, Command::class)();
|
||||
|
||||
if (\is_callable($code)) {
|
||||
$this->setCode($code);
|
||||
}
|
||||
|
||||
if ($processTitle) {
|
||||
parent::setProcessTitle($processTitle);
|
||||
}
|
||||
|
||||
if ($ignoreValidationErrors) {
|
||||
parent::ignoreValidationErrors();
|
||||
}
|
||||
|
||||
$this->ignoreValidation = $ignoreValidationErrors;
|
||||
}
|
||||
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
return $this->command->{$name}(...$arguments);
|
||||
}
|
||||
|
||||
public function getSubscribedSignals(): array
|
||||
{
|
||||
return $this->command->getSubscribedSignals();
|
||||
}
|
||||
|
||||
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
|
||||
{
|
||||
$event = $this->stopwatch->start($this->getName().'.handle_signal');
|
||||
|
||||
$exit = $this->command->handleSignal($signal, $previousExitCode);
|
||||
|
||||
$event->stop();
|
||||
|
||||
if (!isset($this->handledSignals[$signal])) {
|
||||
$this->handledSignals[$signal] = [
|
||||
'handled' => 0,
|
||||
'duration' => 0,
|
||||
'memory' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
++$this->handledSignals[$signal]['handled'];
|
||||
$this->handledSignals[$signal]['duration'] += $event->getDuration();
|
||||
$this->handledSignals[$signal]['memory'] = max(
|
||||
$this->handledSignals[$signal]['memory'],
|
||||
$event->getMemory() >> 20
|
||||
);
|
||||
|
||||
return $exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Calling parent method is required to be used in {@see parent::run()}.
|
||||
*/
|
||||
public function ignoreValidationErrors(): void
|
||||
{
|
||||
$this->ignoreValidation = true;
|
||||
$this->command->ignoreValidationErrors();
|
||||
|
||||
parent::ignoreValidationErrors();
|
||||
}
|
||||
|
||||
public function setApplication(?Application $application = null): void
|
||||
{
|
||||
$this->command->setApplication($application);
|
||||
}
|
||||
|
||||
public function getApplication(): ?Application
|
||||
{
|
||||
return $this->command->getApplication();
|
||||
}
|
||||
|
||||
public function setHelperSet(HelperSet $helperSet): void
|
||||
{
|
||||
$this->command->setHelperSet($helperSet);
|
||||
}
|
||||
|
||||
public function getHelperSet(): ?HelperSet
|
||||
{
|
||||
return $this->command->getHelperSet();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->command->isEnabled();
|
||||
}
|
||||
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
$this->command->complete($input, $suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Calling parent method is required to be used in {@see parent::run()}.
|
||||
*/
|
||||
public function setCode(callable $code): static
|
||||
{
|
||||
if ($code instanceof InvokableCommand) {
|
||||
$r = new \ReflectionFunction(\Closure::bind(function () {
|
||||
return $this->code;
|
||||
}, $code, InvokableCommand::class)());
|
||||
|
||||
$this->invokableCommandInfo = [
|
||||
'class' => $r->getClosureScopeClass()->name,
|
||||
'file' => $r->getFileName(),
|
||||
'line' => $r->getStartLine(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->command->setCode($code);
|
||||
|
||||
return parent::setCode(function (InputInterface $input, OutputInterface $output) use ($code): int {
|
||||
$event = $this->stopwatch->start($this->getName().'.code');
|
||||
|
||||
$this->exitCode = $code($input, $output);
|
||||
|
||||
$event->stop();
|
||||
|
||||
return $this->exitCode;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function mergeApplicationDefinition(bool $mergeArgs = true): void
|
||||
{
|
||||
$this->command->mergeApplicationDefinition($mergeArgs);
|
||||
}
|
||||
|
||||
public function setDefinition(array|InputDefinition $definition): static
|
||||
{
|
||||
$this->command->setDefinition($definition);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDefinition(): InputDefinition
|
||||
{
|
||||
return $this->command->getDefinition();
|
||||
}
|
||||
|
||||
public function getNativeDefinition(): InputDefinition
|
||||
{
|
||||
return $this->command->getNativeDefinition();
|
||||
}
|
||||
|
||||
public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->command->addArgument($name, $mode, $description, $default, $suggestedValues);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
|
||||
{
|
||||
$this->command->addOption($name, $shortcut, $mode, $description, $default, $suggestedValues);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Calling parent method is required to be used in {@see parent::run()}.
|
||||
*/
|
||||
public function setProcessTitle(string $title): static
|
||||
{
|
||||
$this->command->setProcessTitle($title);
|
||||
|
||||
return parent::setProcessTitle($title);
|
||||
}
|
||||
|
||||
public function setHelp(string $help): static
|
||||
{
|
||||
$this->command->setHelp($help);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHelp(): string
|
||||
{
|
||||
return $this->command->getHelp();
|
||||
}
|
||||
|
||||
public function getProcessedHelp(): string
|
||||
{
|
||||
return $this->command->getProcessedHelp();
|
||||
}
|
||||
|
||||
public function getSynopsis(bool $short = false): string
|
||||
{
|
||||
return $this->command->getSynopsis($short);
|
||||
}
|
||||
|
||||
public function addUsage(string $usage): static
|
||||
{
|
||||
$this->command->addUsage($usage);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsages(): array
|
||||
{
|
||||
return $this->command->getUsages();
|
||||
}
|
||||
|
||||
public function getHelper(string $name): HelperInterface
|
||||
{
|
||||
return $this->command->getHelper($name);
|
||||
}
|
||||
|
||||
public function run(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->input = $input;
|
||||
$this->output = $output;
|
||||
$this->arguments = $input->getArguments();
|
||||
$this->options = $input->getOptions();
|
||||
$event = $this->stopwatch->start($this->getName(), 'command');
|
||||
|
||||
try {
|
||||
$this->exitCode = $this->command->run($input, $output);
|
||||
} finally {
|
||||
$event->stop();
|
||||
|
||||
if ($output instanceof ConsoleOutputInterface && $output->isDebug()) {
|
||||
$output->getErrorOutput()->writeln((string) $event);
|
||||
}
|
||||
|
||||
$this->duration = $event->getDuration().' ms';
|
||||
$this->maxMemoryUsage = ($event->getMemory() >> 20).' MiB';
|
||||
|
||||
if ($this->isInteractive) {
|
||||
$this->extractInteractiveInputs($input->getArguments(), $input->getOptions());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$event = $this->stopwatch->start($this->getName().'.init', 'command');
|
||||
|
||||
$this->command->initialize($input, $output);
|
||||
|
||||
$event->stop();
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$this->isInteractive = Command::class !== (new \ReflectionMethod($this->command, 'interact'))->getDeclaringClass()->getName()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $this->stopwatch->start($this->getName().'.interact', 'command');
|
||||
|
||||
$this->command->interact($input, $output);
|
||||
|
||||
$event->stop();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$event = $this->stopwatch->start($this->getName().'.execute', 'command');
|
||||
|
||||
$exitCode = $this->command->execute($input, $output);
|
||||
|
||||
$event->stop();
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
private function extractInteractiveInputs(array $arguments, array $options): void
|
||||
{
|
||||
foreach ($arguments as $argName => $argValue) {
|
||||
if (\array_key_exists($argName, $this->arguments) && $this->arguments[$argName] === $argValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->interactiveInputs[$argName] = $argValue;
|
||||
}
|
||||
|
||||
foreach ($options as $optName => $optValue) {
|
||||
if (\array_key_exists($optName, $this->options) && $this->options[$optName] === $optValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->interactiveInputs['--'.$optName] = $optValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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\CommandLoader;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
/**
|
||||
* @author Robin Chalas <robin.chalas@gmail.com>
|
||||
*/
|
||||
interface CommandLoaderInterface
|
||||
{
|
||||
/**
|
||||
* Loads a command.
|
||||
*
|
||||
* @throws CommandNotFoundException
|
||||
*/
|
||||
public function get(string $name): Command;
|
||||
|
||||
/**
|
||||
* Checks if a command exists.
|
||||
*/
|
||||
public function has(string $name): bool;
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getNames(): array;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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\CommandLoader;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
/**
|
||||
* Loads commands from a PSR-11 container.
|
||||
*
|
||||
* @author Robin Chalas <robin.chalas@gmail.com>
|
||||
*/
|
||||
class ContainerCommandLoader implements CommandLoaderInterface
|
||||
{
|
||||
/**
|
||||
* @param array $commandMap An array with command names as keys and service ids as values
|
||||
*/
|
||||
public function __construct(
|
||||
private ContainerInterface $container,
|
||||
private array $commandMap,
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $name): Command
|
||||
{
|
||||
if (!$this->has($name)) {
|
||||
throw new CommandNotFoundException(\sprintf('Command "%s" does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->container->get($this->commandMap[$name]);
|
||||
}
|
||||
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return isset($this->commandMap[$name]) && $this->container->has($this->commandMap[$name]);
|
||||
}
|
||||
|
||||
public function getNames(): array
|
||||
{
|
||||
return array_keys($this->commandMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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\CommandLoader;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
/**
|
||||
* A simple command loader using factories to instantiate commands lazily.
|
||||
*
|
||||
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
|
||||
*/
|
||||
class FactoryCommandLoader implements CommandLoaderInterface
|
||||
{
|
||||
/**
|
||||
* @param callable[] $factories Indexed by command names
|
||||
*/
|
||||
public function __construct(
|
||||
private array $factories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return isset($this->factories[$name]);
|
||||
}
|
||||
|
||||
public function get(string $name): Command
|
||||
{
|
||||
if (!isset($this->factories[$name])) {
|
||||
throw new CommandNotFoundException(\sprintf('Command "%s" does not exist.', $name));
|
||||
}
|
||||
|
||||
$factory = $this->factories[$name];
|
||||
|
||||
return $factory();
|
||||
}
|
||||
|
||||
public function getNames(): array
|
||||
{
|
||||
return array_keys($this->factories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?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\Completion;
|
||||
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* An input specialized for shell completion.
|
||||
*
|
||||
* This input allows unfinished option names or values and exposes what kind of
|
||||
* completion is expected.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
final class CompletionInput extends ArgvInput
|
||||
{
|
||||
public const TYPE_ARGUMENT_VALUE = 'argument_value';
|
||||
public const TYPE_OPTION_VALUE = 'option_value';
|
||||
public const TYPE_OPTION_NAME = 'option_name';
|
||||
public const TYPE_NONE = 'none';
|
||||
|
||||
private array $tokens;
|
||||
private int $currentIndex;
|
||||
private string $completionType;
|
||||
private ?string $completionName = null;
|
||||
private string $completionValue = '';
|
||||
|
||||
/**
|
||||
* Converts a terminal string into tokens.
|
||||
*
|
||||
* This is required for shell completions without COMP_WORDS support.
|
||||
*/
|
||||
public static function fromString(string $inputStr, int $currentIndex): self
|
||||
{
|
||||
preg_match_all('/(?<=^|\s)([\'"]?)(.+?)(?<!\\\\)\1(?=$|\s)/', $inputStr, $tokens);
|
||||
|
||||
return self::fromTokens($tokens[0], $currentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an input based on an COMP_WORDS token list.
|
||||
*
|
||||
* @param string[] $tokens the set of split tokens (e.g. COMP_WORDS or argv)
|
||||
* @param int $currentIndex the index of the cursor (e.g. COMP_CWORD)
|
||||
*/
|
||||
public static function fromTokens(array $tokens, int $currentIndex): self
|
||||
{
|
||||
$input = new self($tokens);
|
||||
$input->tokens = $tokens;
|
||||
$input->currentIndex = $currentIndex;
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
public function bind(InputDefinition $definition): void
|
||||
{
|
||||
parent::bind($definition);
|
||||
|
||||
$relevantToken = $this->getRelevantToken();
|
||||
if ('-' === $relevantToken[0]) {
|
||||
// the current token is an input option: complete either option name or option value
|
||||
[$optionToken, $optionValue] = explode('=', $relevantToken, 2) + ['', ''];
|
||||
|
||||
$option = $this->getOptionFromToken($optionToken);
|
||||
if (null === $option && !$this->isCursorFree()) {
|
||||
$this->completionType = self::TYPE_OPTION_NAME;
|
||||
$this->completionValue = $relevantToken;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($option?->acceptValue()) {
|
||||
$this->completionType = self::TYPE_OPTION_VALUE;
|
||||
$this->completionName = $option->getName();
|
||||
$this->completionValue = $optionValue ?: (!str_starts_with($optionToken, '--') ? substr($optionToken, 2) : '');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$previousToken = $this->tokens[$this->currentIndex - 1];
|
||||
if ('-' === $previousToken[0] && '' !== trim($previousToken, '-')) {
|
||||
// check if previous option accepted a value
|
||||
$previousOption = $this->getOptionFromToken($previousToken);
|
||||
if ($previousOption?->acceptValue()) {
|
||||
$this->completionType = self::TYPE_OPTION_VALUE;
|
||||
$this->completionName = $previousOption->getName();
|
||||
$this->completionValue = $relevantToken;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// complete argument value
|
||||
$this->completionType = self::TYPE_ARGUMENT_VALUE;
|
||||
|
||||
foreach ($this->definition->getArguments() as $argumentName => $argument) {
|
||||
if (!isset($this->arguments[$argumentName])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$argumentValue = $this->arguments[$argumentName];
|
||||
$this->completionName = $argumentName;
|
||||
if (\is_array($argumentValue)) {
|
||||
$this->completionValue = $argumentValue ? $argumentValue[array_key_last($argumentValue)] : null;
|
||||
} else {
|
||||
$this->completionValue = $argumentValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->currentIndex >= \count($this->tokens)) {
|
||||
if (!isset($this->arguments[$argumentName]) || $this->definition->getArgument($argumentName)->isArray()) {
|
||||
$this->completionName = $argumentName;
|
||||
} else {
|
||||
// we've reached the end
|
||||
$this->completionType = self::TYPE_NONE;
|
||||
$this->completionName = null;
|
||||
}
|
||||
|
||||
$this->completionValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of completion required.
|
||||
*
|
||||
* TYPE_ARGUMENT_VALUE when completing the value of an input argument
|
||||
* TYPE_OPTION_VALUE when completing the value of an input option
|
||||
* TYPE_OPTION_NAME when completing the name of an input option
|
||||
* TYPE_NONE when nothing should be completed
|
||||
*
|
||||
* TYPE_OPTION_NAME and TYPE_NONE are already implemented by the Console component.
|
||||
*
|
||||
* @return self::TYPE_*
|
||||
*/
|
||||
public function getCompletionType(): string
|
||||
{
|
||||
return $this->completionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the input option or argument when completing a value.
|
||||
*
|
||||
* @return string|null returns null when completing an option name
|
||||
*/
|
||||
public function getCompletionName(): ?string
|
||||
{
|
||||
return $this->completionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value already typed by the user (or empty string).
|
||||
*/
|
||||
public function getCompletionValue(): string
|
||||
{
|
||||
return $this->completionValue;
|
||||
}
|
||||
|
||||
public function mustSuggestOptionValuesFor(string $optionName): bool
|
||||
{
|
||||
return self::TYPE_OPTION_VALUE === $this->getCompletionType() && $optionName === $this->getCompletionName();
|
||||
}
|
||||
|
||||
public function mustSuggestArgumentValuesFor(string $argumentName): bool
|
||||
{
|
||||
return self::TYPE_ARGUMENT_VALUE === $this->getCompletionType() && $argumentName === $this->getCompletionName();
|
||||
}
|
||||
|
||||
protected function parseToken(string $token, bool $parseOptions): bool
|
||||
{
|
||||
try {
|
||||
return parent::parseToken($token, $parseOptions);
|
||||
} catch (RuntimeException) {
|
||||
// suppress errors, completed input is almost never valid
|
||||
}
|
||||
|
||||
return $parseOptions;
|
||||
}
|
||||
|
||||
private function getOptionFromToken(string $optionToken): ?InputOption
|
||||
{
|
||||
$optionName = ltrim($optionToken, '-');
|
||||
if (!$optionName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('-' === ($optionToken[1] ?? ' ')) {
|
||||
// long option name
|
||||
return $this->definition->hasOption($optionName) ? $this->definition->getOption($optionName) : null;
|
||||
}
|
||||
|
||||
// short option name
|
||||
return $this->definition->hasShortcut($optionName[0]) ? $this->definition->getOptionForShortcut($optionName[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token of the cursor, or the last token if the cursor is at the end of the input.
|
||||
*/
|
||||
private function getRelevantToken(): string
|
||||
{
|
||||
return $this->tokens[$this->isCursorFree() ? $this->currentIndex - 1 : $this->currentIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cursor is "free" (i.e. at the end of the input preceded by a space).
|
||||
*/
|
||||
private function isCursorFree(): bool
|
||||
{
|
||||
$nrOfTokens = \count($this->tokens);
|
||||
if ($this->currentIndex > $nrOfTokens) {
|
||||
throw new \LogicException('Current index is invalid, it must be the number of input tokens or one more.');
|
||||
}
|
||||
|
||||
return $this->currentIndex >= $nrOfTokens;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$str = '';
|
||||
foreach ($this->tokens as $i => $token) {
|
||||
$str .= $token;
|
||||
|
||||
if ($this->currentIndex === $i) {
|
||||
$str .= '|';
|
||||
}
|
||||
|
||||
$str .= ' ';
|
||||
}
|
||||
|
||||
if ($this->currentIndex > $i) {
|
||||
$str .= '|';
|
||||
}
|
||||
|
||||
return rtrim($str);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?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\Completion;
|
||||
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* Stores all completion suggestions for the current input.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
final class CompletionSuggestions
|
||||
{
|
||||
private array $valueSuggestions = [];
|
||||
private array $optionSuggestions = [];
|
||||
|
||||
/**
|
||||
* Add a suggested value for an input option or argument.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function suggestValue(string|Suggestion $value): static
|
||||
{
|
||||
$this->valueSuggestions[] = !$value instanceof Suggestion ? new Suggestion($value) : $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple suggested values at once for an input option or argument.
|
||||
*
|
||||
* @param list<string|Suggestion> $values
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function suggestValues(array $values): static
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
$this->suggestValue($value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a suggestion for an input option name.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function suggestOption(InputOption $option): static
|
||||
{
|
||||
$this->optionSuggestions[] = $option;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple suggestions for input option names at once.
|
||||
*
|
||||
* @param InputOption[] $options
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function suggestOptions(array $options): static
|
||||
{
|
||||
foreach ($options as $option) {
|
||||
$this->suggestOption($option);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InputOption[]
|
||||
*/
|
||||
public function getOptionSuggestions(): array
|
||||
{
|
||||
return $this->optionSuggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Suggestion[]
|
||||
*/
|
||||
public function getValueSuggestions(): array
|
||||
{
|
||||
return $this->valueSuggestions;
|
||||
}
|
||||
}
|
||||
@@ -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\Completion\Output;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
class BashCompletionOutput implements CompletionOutputInterface
|
||||
{
|
||||
public function write(CompletionSuggestions $suggestions, OutputInterface $output): void
|
||||
{
|
||||
$values = $suggestions->getValueSuggestions();
|
||||
foreach ($suggestions->getOptionSuggestions() as $option) {
|
||||
$values[] = '--'.$option->getName();
|
||||
if ($option->isNegatable()) {
|
||||
$values[] = '--no-'.$option->getName();
|
||||
}
|
||||
}
|
||||
$output->writeln(implode("\n", $values));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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\Completion\Output;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Transforms the {@see CompletionSuggestions} object into output readable by the shell completion.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
interface CompletionOutputInterface
|
||||
{
|
||||
public function write(CompletionSuggestions $suggestions, OutputInterface $output): void;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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\Completion\Output;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Guillaume Aveline <guillaume.aveline@pm.me>
|
||||
*/
|
||||
class FishCompletionOutput implements CompletionOutputInterface
|
||||
{
|
||||
public function write(CompletionSuggestions $suggestions, OutputInterface $output): void
|
||||
{
|
||||
$values = [];
|
||||
foreach ($suggestions->getValueSuggestions() as $value) {
|
||||
$values[] = $value->getValue().($value->getDescription() ? "\t".$value->getDescription() : '');
|
||||
}
|
||||
foreach ($suggestions->getOptionSuggestions() as $option) {
|
||||
$values[] = '--'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : '');
|
||||
if ($option->isNegatable()) {
|
||||
$values[] = '--no-'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : '');
|
||||
}
|
||||
}
|
||||
$output->write(implode("\n", $values));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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\Completion\Output;
|
||||
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Jitendra A <adhocore@gmail.com>
|
||||
*/
|
||||
class ZshCompletionOutput implements CompletionOutputInterface
|
||||
{
|
||||
public function write(CompletionSuggestions $suggestions, OutputInterface $output): void
|
||||
{
|
||||
$values = [];
|
||||
foreach ($suggestions->getValueSuggestions() as $value) {
|
||||
$values[] = $value->getValue().($value->getDescription() ? "\t".$value->getDescription() : '');
|
||||
}
|
||||
foreach ($suggestions->getOptionSuggestions() as $option) {
|
||||
$values[] = '--'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : '');
|
||||
if ($option->isNegatable()) {
|
||||
$values[] = '--no-'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : '');
|
||||
}
|
||||
}
|
||||
$output->write(implode("\n", $values)."\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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\Completion;
|
||||
|
||||
/**
|
||||
* Represents a single suggested value.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
class Suggestion implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $value,
|
||||
private readonly string $description = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getValue();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\Console\Event\ConsoleCommandEvent;
|
||||
use Symfony\Component\Console\Event\ConsoleErrorEvent;
|
||||
use Symfony\Component\Console\Event\ConsoleSignalEvent;
|
||||
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
|
||||
|
||||
/**
|
||||
* Contains all events dispatched by an Application.
|
||||
*
|
||||
* @author Francesco Levorato <git@flevour.net>
|
||||
*/
|
||||
final class ConsoleEvents
|
||||
{
|
||||
/**
|
||||
* The COMMAND event allows you to attach listeners before any command is
|
||||
* executed by the console. It also allows you to modify the command, input and output
|
||||
* before they are handed to the command.
|
||||
*
|
||||
* @Event("Symfony\Component\Console\Event\ConsoleCommandEvent")
|
||||
*/
|
||||
public const COMMAND = 'console.command';
|
||||
|
||||
/**
|
||||
* The SIGNAL event allows you to perform some actions
|
||||
* after the command execution was interrupted.
|
||||
*
|
||||
* @Event("Symfony\Component\Console\Event\ConsoleSignalEvent")
|
||||
*/
|
||||
public const SIGNAL = 'console.signal';
|
||||
|
||||
/**
|
||||
* The TERMINATE event allows you to attach listeners after a command is
|
||||
* executed by the console.
|
||||
*
|
||||
* @Event("Symfony\Component\Console\Event\ConsoleTerminateEvent")
|
||||
*/
|
||||
public const TERMINATE = 'console.terminate';
|
||||
|
||||
/**
|
||||
* The ERROR event occurs when an uncaught exception or error appears.
|
||||
*
|
||||
* This event allows you to deal with the exception/error or
|
||||
* to modify the thrown exception.
|
||||
*
|
||||
* @Event("Symfony\Component\Console\Event\ConsoleErrorEvent")
|
||||
*/
|
||||
public const ERROR = 'console.error';
|
||||
|
||||
/**
|
||||
* Event aliases.
|
||||
*
|
||||
* These aliases can be consumed by RegisterListenersPass.
|
||||
*/
|
||||
public const ALIASES = [
|
||||
ConsoleCommandEvent::class => self::COMMAND,
|
||||
ConsoleErrorEvent::class => self::ERROR,
|
||||
ConsoleSignalEvent::class => self::SIGNAL,
|
||||
ConsoleTerminateEvent::class => self::TERMINATE,
|
||||
];
|
||||
}
|
||||
Vendored
+204
@@ -0,0 +1,204 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Pierre du Plessis <pdples@gmail.com>
|
||||
*/
|
||||
final class Cursor
|
||||
{
|
||||
/** @var resource */
|
||||
private $input;
|
||||
|
||||
/**
|
||||
* @param resource|null $input
|
||||
*/
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
$input = null,
|
||||
) {
|
||||
$this->input = $input ?? (\defined('STDIN') ? \STDIN : fopen('php://input', 'r+'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveUp(int $lines = 1): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%dA", $lines));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveDown(int $lines = 1): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%dB", $lines));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveRight(int $columns = 1): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%dC", $columns));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveLeft(int $columns = 1): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%dD", $columns));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveToColumn(int $column): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%dG", $column));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function moveToPosition(int $column, int $row): static
|
||||
{
|
||||
$this->output->write(\sprintf("\x1b[%d;%dH", $row + 1, $column));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function savePosition(): static
|
||||
{
|
||||
$this->output->write("\x1b7");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function restorePosition(): static
|
||||
{
|
||||
$this->output->write("\x1b8");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function hide(): static
|
||||
{
|
||||
$this->output->write("\x1b[?25l");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function show(): static
|
||||
{
|
||||
$this->output->write("\x1b[?25h\x1b[?0c");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all the output from the current line.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearLine(): static
|
||||
{
|
||||
$this->output->write("\x1b[2K");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all the output from the current line after the current position.
|
||||
*/
|
||||
public function clearLineAfter(): self
|
||||
{
|
||||
$this->output->write("\x1b[K");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all the output from the cursors' current position to the end of the screen.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearOutput(): static
|
||||
{
|
||||
$this->output->write("\x1b[0J");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire screen.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function clearScreen(): static
|
||||
{
|
||||
$this->output->write("\x1b[2J");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current cursor position as x,y coordinates.
|
||||
*/
|
||||
public function getCurrentPosition(): array
|
||||
{
|
||||
static $isTtySupported;
|
||||
|
||||
if (!$isTtySupported ??= '/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT)) {
|
||||
return [1, 1];
|
||||
}
|
||||
|
||||
$sttyMode = shell_exec('stty -g');
|
||||
shell_exec('stty -icanon -echo');
|
||||
|
||||
@fwrite($this->input, "\033[6n");
|
||||
|
||||
$code = trim(fread($this->input, 1024));
|
||||
|
||||
shell_exec(\sprintf('stty %s', $sttyMode));
|
||||
|
||||
sscanf($code, "\033[%d;%dR", $row, $col);
|
||||
|
||||
return [$col, $row];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?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\DataCollector;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Debug\CliRequest;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\SignalRegistry\SignalMap;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
|
||||
use Symfony\Component\VarDumper\Cloner\Data;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @author Jules Pietri <jules@heahprod.com>
|
||||
*/
|
||||
final class CommandDataCollector extends DataCollector
|
||||
{
|
||||
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
|
||||
{
|
||||
if (!$request instanceof CliRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
$command = $request->command;
|
||||
$application = $command->getApplication();
|
||||
|
||||
$this->data = [
|
||||
'command' => $command->invokableCommandInfo ?? $this->cloneVar($command->command),
|
||||
'exit_code' => $command->exitCode,
|
||||
'interrupted_by_signal' => $command->interruptedBySignal,
|
||||
'duration' => $command->duration,
|
||||
'max_memory_usage' => $command->maxMemoryUsage,
|
||||
'verbosity_level' => match ($command->output->getVerbosity()) {
|
||||
OutputInterface::VERBOSITY_QUIET => 'quiet',
|
||||
OutputInterface::VERBOSITY_NORMAL => 'normal',
|
||||
OutputInterface::VERBOSITY_VERBOSE => 'verbose',
|
||||
OutputInterface::VERBOSITY_VERY_VERBOSE => 'very verbose',
|
||||
OutputInterface::VERBOSITY_DEBUG => 'debug',
|
||||
},
|
||||
'interactive' => $command->isInteractive,
|
||||
'validate_input' => !$command->ignoreValidation,
|
||||
'enabled' => $command->isEnabled(),
|
||||
'visible' => !$command->isHidden(),
|
||||
'input' => $this->cloneVar($command->input),
|
||||
'output' => $this->cloneVar($command->output),
|
||||
'interactive_inputs' => array_map($this->cloneVar(...), $command->interactiveInputs),
|
||||
'signalable' => $command->getSubscribedSignals(),
|
||||
'handled_signals' => $command->handledSignals,
|
||||
'helper_set' => array_map($this->cloneVar(...), iterator_to_array($command->getHelperSet())),
|
||||
];
|
||||
|
||||
$baseDefinition = $application->getDefinition();
|
||||
|
||||
foreach ($command->arguments as $argName => $argValue) {
|
||||
if ($baseDefinition->hasArgument($argName)) {
|
||||
$this->data['application_inputs'][$argName] = $this->cloneVar($argValue);
|
||||
} else {
|
||||
$this->data['arguments'][$argName] = $this->cloneVar($argValue);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($command->options as $optName => $optValue) {
|
||||
if ($baseDefinition->hasOption($optName)) {
|
||||
$this->data['application_inputs']['--'.$optName] = $this->cloneVar($optValue);
|
||||
} else {
|
||||
$this->data['options'][$optName] = $this->cloneVar($optValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'command';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* class?: class-string,
|
||||
* executor?: string,
|
||||
* file: string,
|
||||
* line: int,
|
||||
* }
|
||||
*/
|
||||
public function getCommand(): array
|
||||
{
|
||||
if (\is_array($this->data['command'])) {
|
||||
return $this->data['command'];
|
||||
}
|
||||
|
||||
$class = $this->data['command']->getType();
|
||||
$r = new \ReflectionMethod($class, 'execute');
|
||||
|
||||
if (Command::class !== $r->getDeclaringClass()) {
|
||||
return [
|
||||
'executor' => $class.'::'.$r->name,
|
||||
'file' => $r->getFileName(),
|
||||
'line' => $r->getStartLine(),
|
||||
];
|
||||
}
|
||||
|
||||
$r = new \ReflectionClass($class);
|
||||
|
||||
return [
|
||||
'class' => $class,
|
||||
'file' => $r->getFileName(),
|
||||
'line' => $r->getStartLine(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getInterruptedBySignal(): ?string
|
||||
{
|
||||
if (isset($this->data['interrupted_by_signal'])) {
|
||||
return \sprintf('%s (%d)', SignalMap::getSignalName($this->data['interrupted_by_signal']), $this->data['interrupted_by_signal']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDuration(): string
|
||||
{
|
||||
return $this->data['duration'];
|
||||
}
|
||||
|
||||
public function getMaxMemoryUsage(): string
|
||||
{
|
||||
return $this->data['max_memory_usage'];
|
||||
}
|
||||
|
||||
public function getVerbosityLevel(): string
|
||||
{
|
||||
return $this->data['verbosity_level'];
|
||||
}
|
||||
|
||||
public function getInteractive(): bool
|
||||
{
|
||||
return $this->data['interactive'];
|
||||
}
|
||||
|
||||
public function getValidateInput(): bool
|
||||
{
|
||||
return $this->data['validate_input'];
|
||||
}
|
||||
|
||||
public function getEnabled(): bool
|
||||
{
|
||||
return $this->data['enabled'];
|
||||
}
|
||||
|
||||
public function getVisible(): bool
|
||||
{
|
||||
return $this->data['visible'];
|
||||
}
|
||||
|
||||
public function getInput(): Data
|
||||
{
|
||||
return $this->data['input'];
|
||||
}
|
||||
|
||||
public function getOutput(): Data
|
||||
{
|
||||
return $this->data['output'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Data[]
|
||||
*/
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->data['arguments'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Data[]
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->data['options'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Data[]
|
||||
*/
|
||||
public function getApplicationInputs(): array
|
||||
{
|
||||
return $this->data['application_inputs'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Data[]
|
||||
*/
|
||||
public function getInteractiveInputs(): array
|
||||
{
|
||||
return $this->data['interactive_inputs'] ?? [];
|
||||
}
|
||||
|
||||
public function getSignalable(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (int $signal): string => \sprintf('%s (%d)', SignalMap::getSignalName($signal), $signal),
|
||||
$this->data['signalable']
|
||||
);
|
||||
}
|
||||
|
||||
public function getHandledSignals(): array
|
||||
{
|
||||
$keys = array_map(
|
||||
static fn (int $signal): string => \sprintf('%s (%d)', SignalMap::getSignalName($signal), $signal),
|
||||
array_keys($this->data['handled_signals'])
|
||||
);
|
||||
|
||||
return array_combine($keys, array_values($this->data['handled_signals']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Data[]
|
||||
*/
|
||||
public function getHelperSet(): array
|
||||
{
|
||||
return $this->data['helper_set'] ?? [];
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->data = [];
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<?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\Debug;
|
||||
|
||||
use Symfony\Component\Console\Command\TraceableCommand;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CliRequest extends Request
|
||||
{
|
||||
public function __construct(
|
||||
public readonly TraceableCommand $command,
|
||||
) {
|
||||
parent::__construct(
|
||||
attributes: ['_controller' => \get_class($command->command), '_virtual_type' => 'command'],
|
||||
server: $_SERVER,
|
||||
);
|
||||
}
|
||||
|
||||
// Methods below allow to populate a profile, thus enable search and filtering
|
||||
public function getUri(): string
|
||||
{
|
||||
if ($this->server->has('SYMFONY_CLI_BINARY_NAME')) {
|
||||
$binary = $this->server->get('SYMFONY_CLI_BINARY_NAME').' console';
|
||||
} else {
|
||||
$binary = $this->server->get('argv')[0];
|
||||
}
|
||||
|
||||
return $binary.' '.$this->command->input;
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->command->isInteractive ? 'INTERACTIVE' : 'BATCH';
|
||||
}
|
||||
|
||||
public function getResponse(): Response
|
||||
{
|
||||
return new class($this->command->exitCode) extends Response {
|
||||
public function __construct(private readonly int $exitCode)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function getClientIp(): string
|
||||
{
|
||||
$application = $this->command->getApplication();
|
||||
|
||||
return $application->getName().' '.$application->getVersion();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?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\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
|
||||
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
use Symfony\Component\DependencyInjection\TypedReference;
|
||||
|
||||
/**
|
||||
* Registers console commands.
|
||||
*
|
||||
* @author Grégoire Pineau <lyrixx@lyrixx.info>
|
||||
*/
|
||||
class AddConsoleCommandPass implements CompilerPassInterface
|
||||
{
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
$commandServices = $container->findTaggedServiceIds('console.command', true);
|
||||
$lazyCommandMap = [];
|
||||
$lazyCommandRefs = [];
|
||||
$serviceIds = [];
|
||||
|
||||
foreach ($commandServices as $id => $tags) {
|
||||
$definition = $container->getDefinition($id);
|
||||
$class = $container->getParameterBag()->resolveValue($definition->getClass());
|
||||
|
||||
if (!$r = $container->getReflectionClass($class)) {
|
||||
throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id));
|
||||
}
|
||||
|
||||
if (!$r->isSubclassOf(Command::class)) {
|
||||
if (!$r->hasMethod('__invoke')) {
|
||||
throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "__invoke()" method.', $id, 'console.command', Command::class));
|
||||
}
|
||||
|
||||
$invokableRef = new Reference($id);
|
||||
$definition = $container->register($id .= '.command', $class = Command::class)
|
||||
->addMethodCall('setCode', [$invokableRef]);
|
||||
} else {
|
||||
$invokableRef = null;
|
||||
}
|
||||
|
||||
$definition->addTag('container.no_preload');
|
||||
|
||||
/** @var AsCommand|null $attribute */
|
||||
$attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
|
||||
|
||||
if (Command::class !== (new \ReflectionMethod($class, 'getDefaultName'))->class) {
|
||||
trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultName()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', $class);
|
||||
|
||||
$defaultName = $class::getDefaultName();
|
||||
} else {
|
||||
$defaultName = $attribute?->name;
|
||||
}
|
||||
|
||||
$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? '');
|
||||
$aliases = explode('|', $aliases);
|
||||
$commandName = array_shift($aliases);
|
||||
|
||||
if ($isHidden = '' === $commandName) {
|
||||
$commandName = array_shift($aliases);
|
||||
}
|
||||
|
||||
if (null === $commandName) {
|
||||
if (!$definition->isPublic() || $definition->isPrivate() || $definition->hasTag('container.private')) {
|
||||
$commandId = 'console.command.public_alias.'.$id;
|
||||
$container->setAlias($commandId, $id)->setPublic(true);
|
||||
$id = $commandId;
|
||||
}
|
||||
$serviceIds[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = $tags[0]['description'] ?? null;
|
||||
$help = $tags[0]['help'] ?? null;
|
||||
|
||||
unset($tags[0]);
|
||||
$lazyCommandMap[$commandName] = $id;
|
||||
$lazyCommandRefs[$id] = new TypedReference($id, $class);
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$lazyCommandMap[$alias] = $id;
|
||||
}
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (isset($tag['command'])) {
|
||||
$aliases[] = $tag['command'];
|
||||
$lazyCommandMap[$tag['command']] = $id;
|
||||
}
|
||||
|
||||
$description ??= $tag['description'] ?? null;
|
||||
$help ??= $tag['help'] ?? null;
|
||||
}
|
||||
|
||||
$definition->addMethodCall('setName', [$commandName]);
|
||||
|
||||
if ($aliases) {
|
||||
$definition->addMethodCall('setAliases', [$aliases]);
|
||||
}
|
||||
|
||||
if ($isHidden) {
|
||||
$definition->addMethodCall('setHidden', [true]);
|
||||
}
|
||||
|
||||
if ($help && $invokableRef) {
|
||||
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
|
||||
}
|
||||
|
||||
if (!$description) {
|
||||
if (Command::class !== (new \ReflectionMethod($class, 'getDefaultDescription'))->class) {
|
||||
trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultDescription()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', $class);
|
||||
|
||||
$description = $class::getDefaultDescription();
|
||||
} else {
|
||||
$description = $attribute?->description;
|
||||
}
|
||||
}
|
||||
|
||||
if ($description) {
|
||||
$definition->addMethodCall('setDescription', [str_replace('%', '%%', $description)]);
|
||||
|
||||
$container->register('.'.$id.'.lazy', LazyCommand::class)
|
||||
->setArguments([$commandName, $aliases, $description, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
|
||||
|
||||
$lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy');
|
||||
}
|
||||
}
|
||||
|
||||
$container
|
||||
->register('console.command_loader', ContainerCommandLoader::class)
|
||||
->setPublic(true)
|
||||
->addTag('container.no_preload')
|
||||
->setArguments([ServiceLocatorTagPass::register($container, $lazyCommandRefs), $lazyCommandMap]);
|
||||
|
||||
$container->setParameter('console.command.ids', $serviceIds);
|
||||
}
|
||||
}
|
||||
@@ -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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
/**
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ApplicationDescription
|
||||
{
|
||||
public const GLOBAL_NAMESPACE = '_global';
|
||||
|
||||
private array $namespaces;
|
||||
|
||||
/**
|
||||
* @var array<string, Command>
|
||||
*/
|
||||
private array $commands;
|
||||
|
||||
/**
|
||||
* @var array<string, Command>
|
||||
*/
|
||||
private array $aliases = [];
|
||||
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private ?string $namespace = null,
|
||||
private bool $showHidden = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getNamespaces(): array
|
||||
{
|
||||
if (!isset($this->namespaces)) {
|
||||
$this->inspectApplication();
|
||||
}
|
||||
|
||||
return $this->namespaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Command[]
|
||||
*/
|
||||
public function getCommands(): array
|
||||
{
|
||||
if (!isset($this->commands)) {
|
||||
$this->inspectApplication();
|
||||
}
|
||||
|
||||
return $this->commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommandNotFoundException
|
||||
*/
|
||||
public function getCommand(string $name): Command
|
||||
{
|
||||
if (!isset($this->commands[$name]) && !isset($this->aliases[$name])) {
|
||||
throw new CommandNotFoundException(\sprintf('Command "%s" does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->commands[$name] ?? $this->aliases[$name];
|
||||
}
|
||||
|
||||
private function inspectApplication(): void
|
||||
{
|
||||
$this->commands = [];
|
||||
$this->namespaces = [];
|
||||
|
||||
$all = $this->application->all($this->namespace ? $this->application->findNamespace($this->namespace) : null);
|
||||
foreach ($this->sortCommands($all) as $namespace => $commands) {
|
||||
$names = [];
|
||||
|
||||
/** @var Command $command */
|
||||
foreach ($commands as $name => $command) {
|
||||
if (!$command->getName() || (!$this->showHidden && $command->isHidden())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($command->getName() === $name) {
|
||||
$this->commands[$name] = $command;
|
||||
} else {
|
||||
$this->aliases[$name] = $command;
|
||||
}
|
||||
|
||||
$names[] = $name;
|
||||
}
|
||||
|
||||
$this->namespaces[$namespace] = ['id' => $namespace, 'commands' => $names];
|
||||
}
|
||||
}
|
||||
|
||||
private function sortCommands(array $commands): array
|
||||
{
|
||||
$namespacedCommands = [];
|
||||
$globalCommands = [];
|
||||
$sortedCommands = [];
|
||||
foreach ($commands as $name => $command) {
|
||||
$key = $this->application->extractNamespace($name, 1);
|
||||
if (\in_array($key, ['', self::GLOBAL_NAMESPACE], true)) {
|
||||
$globalCommands[$name] = $command;
|
||||
} else {
|
||||
$namespacedCommands[$key][$name] = $command;
|
||||
}
|
||||
}
|
||||
|
||||
if ($globalCommands) {
|
||||
ksort($globalCommands);
|
||||
$sortedCommands[self::GLOBAL_NAMESPACE] = $globalCommands;
|
||||
}
|
||||
|
||||
if ($namespacedCommands) {
|
||||
ksort($namespacedCommands, \SORT_STRING);
|
||||
foreach ($namespacedCommands as $key => $commandsSet) {
|
||||
ksort($commandsSet);
|
||||
$sortedCommands[$key] = $commandsSet;
|
||||
}
|
||||
}
|
||||
|
||||
return $sortedCommands;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class Descriptor implements DescriptorInterface
|
||||
{
|
||||
protected OutputInterface $output;
|
||||
|
||||
public function describe(OutputInterface $output, object $object, array $options = []): void
|
||||
{
|
||||
$this->output = $output;
|
||||
|
||||
match (true) {
|
||||
$object instanceof InputArgument => $this->describeInputArgument($object, $options),
|
||||
$object instanceof InputOption => $this->describeInputOption($object, $options),
|
||||
$object instanceof InputDefinition => $this->describeInputDefinition($object, $options),
|
||||
$object instanceof Command => $this->describeCommand($object, $options),
|
||||
$object instanceof Application => $this->describeApplication($object, $options),
|
||||
default => throw new InvalidArgumentException(\sprintf('Object of type "%s" is not describable.', get_debug_type($object))),
|
||||
};
|
||||
}
|
||||
|
||||
protected function write(string $content, bool $decorated = false): void
|
||||
{
|
||||
$this->output->write($content, false, $decorated ? OutputInterface::OUTPUT_NORMAL : OutputInterface::OUTPUT_RAW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an InputArgument instance.
|
||||
*/
|
||||
abstract protected function describeInputArgument(InputArgument $argument, array $options = []): void;
|
||||
|
||||
/**
|
||||
* Describes an InputOption instance.
|
||||
*/
|
||||
abstract protected function describeInputOption(InputOption $option, array $options = []): void;
|
||||
|
||||
/**
|
||||
* Describes an InputDefinition instance.
|
||||
*/
|
||||
abstract protected function describeInputDefinition(InputDefinition $definition, array $options = []): void;
|
||||
|
||||
/**
|
||||
* Describes a Command instance.
|
||||
*/
|
||||
abstract protected function describeCommand(Command $command, array $options = []): void;
|
||||
|
||||
/**
|
||||
* Describes an Application instance.
|
||||
*/
|
||||
abstract protected function describeApplication(Application $application, array $options = []): void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Descriptor interface.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*/
|
||||
interface DescriptorInterface
|
||||
{
|
||||
public function describe(OutputInterface $output, object $object, array $options = []): void;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* JSON descriptor.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class JsonDescriptor extends Descriptor
|
||||
{
|
||||
protected function describeInputArgument(InputArgument $argument, array $options = []): void
|
||||
{
|
||||
$this->writeData($this->getInputArgumentData($argument), $options);
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option, array $options = []): void
|
||||
{
|
||||
$this->writeData($this->getInputOptionData($option), $options);
|
||||
if ($option->isNegatable()) {
|
||||
$this->writeData($this->getInputOptionData($option, true), $options);
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(InputDefinition $definition, array $options = []): void
|
||||
{
|
||||
$this->writeData($this->getInputDefinitionData($definition), $options);
|
||||
}
|
||||
|
||||
protected function describeCommand(Command $command, array $options = []): void
|
||||
{
|
||||
$this->writeData($this->getCommandData($command, $options['short'] ?? false), $options);
|
||||
}
|
||||
|
||||
protected function describeApplication(Application $application, array $options = []): void
|
||||
{
|
||||
$describedNamespace = $options['namespace'] ?? null;
|
||||
$description = new ApplicationDescription($application, $describedNamespace, true);
|
||||
$commands = [];
|
||||
|
||||
foreach ($description->getCommands() as $command) {
|
||||
$commands[] = $this->getCommandData($command, $options['short'] ?? false);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
if ('UNKNOWN' !== $application->getName()) {
|
||||
$data['application']['name'] = $application->getName();
|
||||
if ('UNKNOWN' !== $application->getVersion()) {
|
||||
$data['application']['version'] = $application->getVersion();
|
||||
}
|
||||
}
|
||||
|
||||
$data['commands'] = $commands;
|
||||
|
||||
if ($describedNamespace) {
|
||||
$data['namespace'] = $describedNamespace;
|
||||
} else {
|
||||
$data['namespaces'] = array_values($description->getNamespaces());
|
||||
}
|
||||
|
||||
$this->writeData($data, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data as json.
|
||||
*/
|
||||
private function writeData(array $data, array $options): void
|
||||
{
|
||||
$flags = $options['json_encoding'] ?? 0;
|
||||
|
||||
$this->write(json_encode($data, $flags));
|
||||
}
|
||||
|
||||
private function getInputArgumentData(InputArgument $argument): array
|
||||
{
|
||||
return [
|
||||
'name' => $argument->getName(),
|
||||
'is_required' => $argument->isRequired(),
|
||||
'is_array' => $argument->isArray(),
|
||||
'description' => preg_replace('/\s*[\r\n]\s*/', ' ', $argument->getDescription()),
|
||||
'default' => \INF === $argument->getDefault() ? 'INF' : $argument->getDefault(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getInputOptionData(InputOption $option, bool $negated = false): array
|
||||
{
|
||||
return $negated ? [
|
||||
'name' => '--no-'.$option->getName(),
|
||||
'shortcut' => '',
|
||||
'accept_value' => false,
|
||||
'is_value_required' => false,
|
||||
'is_multiple' => false,
|
||||
'description' => 'Negate the "--'.$option->getName().'" option',
|
||||
'default' => null === $option->getDefault() ? null : !$option->getDefault(),
|
||||
] : [
|
||||
'name' => '--'.$option->getName(),
|
||||
'shortcut' => $option->getShortcut() ? '-'.str_replace('|', '|-', $option->getShortcut()) : '',
|
||||
'accept_value' => $option->acceptValue(),
|
||||
'is_value_required' => $option->isValueRequired(),
|
||||
'is_multiple' => $option->isArray(),
|
||||
'description' => preg_replace('/\s*[\r\n]\s*/', ' ', $option->getDescription()),
|
||||
'default' => \INF === $option->getDefault() ? 'INF' : $option->getDefault(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getInputDefinitionData(InputDefinition $definition): array
|
||||
{
|
||||
$inputArguments = [];
|
||||
foreach ($definition->getArguments() as $name => $argument) {
|
||||
$inputArguments[$name] = $this->getInputArgumentData($argument);
|
||||
}
|
||||
|
||||
$inputOptions = [];
|
||||
foreach ($definition->getOptions() as $name => $option) {
|
||||
$inputOptions[$name] = $this->getInputOptionData($option);
|
||||
if ($option->isNegatable()) {
|
||||
$inputOptions['no-'.$name] = $this->getInputOptionData($option, true);
|
||||
}
|
||||
}
|
||||
|
||||
return ['arguments' => $inputArguments, 'options' => $inputOptions];
|
||||
}
|
||||
|
||||
private function getCommandData(Command $command, bool $short = false): array
|
||||
{
|
||||
$data = [
|
||||
'name' => $command->getName(),
|
||||
'description' => $command->getDescription(),
|
||||
];
|
||||
|
||||
if ($short) {
|
||||
$data += [
|
||||
'usage' => $command->getAliases(),
|
||||
];
|
||||
} else {
|
||||
$command->mergeApplicationDefinition(false);
|
||||
|
||||
$data += [
|
||||
'usage' => array_merge([$command->getSynopsis()], $command->getUsages(), $command->getAliases()),
|
||||
'help' => $command->getProcessedHelp(),
|
||||
'definition' => $this->getInputDefinitionData($command->getDefinition()),
|
||||
];
|
||||
}
|
||||
|
||||
$data['hidden'] = $command->isHidden();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Helper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Markdown descriptor.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class MarkdownDescriptor extends Descriptor
|
||||
{
|
||||
public function describe(OutputInterface $output, object $object, array $options = []): void
|
||||
{
|
||||
$decorated = $output->isDecorated();
|
||||
$output->setDecorated(false);
|
||||
|
||||
parent::describe($output, $object, $options);
|
||||
|
||||
$output->setDecorated($decorated);
|
||||
}
|
||||
|
||||
protected function write(string $content, bool $decorated = true): void
|
||||
{
|
||||
parent::write($content, $decorated);
|
||||
}
|
||||
|
||||
protected function describeInputArgument(InputArgument $argument, array $options = []): void
|
||||
{
|
||||
$this->write(
|
||||
'#### `'.($argument->getName() ?: '<none>')."`\n\n"
|
||||
.($argument->getDescription() ? preg_replace('/\s*[\r\n]\s*/', "\n", $argument->getDescription())."\n\n" : '')
|
||||
.'* Is required: '.($argument->isRequired() ? 'yes' : 'no')."\n"
|
||||
.'* Is array: '.($argument->isArray() ? 'yes' : 'no')."\n"
|
||||
.'* Default: `'.str_replace("\n", '', var_export($argument->getDefault(), true)).'`'
|
||||
);
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option, array $options = []): void
|
||||
{
|
||||
$name = '--'.$option->getName();
|
||||
if ($option->isNegatable()) {
|
||||
$name .= '|--no-'.$option->getName();
|
||||
}
|
||||
if ($option->getShortcut()) {
|
||||
$name .= '|-'.str_replace('|', '|-', $option->getShortcut()).'';
|
||||
}
|
||||
|
||||
$this->write(
|
||||
'#### `'.$name.'`'."\n\n"
|
||||
.($option->getDescription() ? preg_replace('/\s*[\r\n]\s*/', "\n", $option->getDescription())."\n\n" : '')
|
||||
.'* Accept value: '.($option->acceptValue() ? 'yes' : 'no')."\n"
|
||||
.'* Is value required: '.($option->isValueRequired() ? 'yes' : 'no')."\n"
|
||||
.'* Is multiple: '.($option->isArray() ? 'yes' : 'no')."\n"
|
||||
.'* Is negatable: '.($option->isNegatable() ? 'yes' : 'no')."\n"
|
||||
.'* Default: `'.str_replace("\n", '', var_export($option->getDefault(), true)).'`'
|
||||
);
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(InputDefinition $definition, array $options = []): void
|
||||
{
|
||||
if ($showArguments = \count($definition->getArguments()) > 0) {
|
||||
$this->write('### Arguments');
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$this->write("\n\n");
|
||||
$this->describeInputArgument($argument);
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($definition->getOptions()) > 0) {
|
||||
if ($showArguments) {
|
||||
$this->write("\n\n");
|
||||
}
|
||||
|
||||
$this->write('### Options');
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
$this->write("\n\n");
|
||||
$this->describeInputOption($option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeCommand(Command $command, array $options = []): void
|
||||
{
|
||||
if ($options['short'] ?? false) {
|
||||
$this->write(
|
||||
'`'.$command->getName()."`\n"
|
||||
.str_repeat('-', Helper::width($command->getName()) + 2)."\n\n"
|
||||
.($command->getDescription() ? $command->getDescription()."\n\n" : '')
|
||||
.'### Usage'."\n\n"
|
||||
.array_reduce($command->getAliases(), fn ($carry, $usage) => $carry.'* `'.$usage.'`'."\n")
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$command->mergeApplicationDefinition(false);
|
||||
|
||||
$this->write(
|
||||
'`'.$command->getName()."`\n"
|
||||
.str_repeat('-', Helper::width($command->getName()) + 2)."\n\n"
|
||||
.($command->getDescription() ? $command->getDescription()."\n\n" : '')
|
||||
.'### Usage'."\n\n"
|
||||
.array_reduce(array_merge([$command->getSynopsis()], $command->getAliases(), $command->getUsages()), fn ($carry, $usage) => $carry.'* `'.$usage.'`'."\n")
|
||||
);
|
||||
|
||||
if ($help = $command->getProcessedHelp()) {
|
||||
$this->write("\n");
|
||||
$this->write($help);
|
||||
}
|
||||
|
||||
$definition = $command->getDefinition();
|
||||
if ($definition->getOptions() || $definition->getArguments()) {
|
||||
$this->write("\n\n");
|
||||
$this->describeInputDefinition($definition);
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeApplication(Application $application, array $options = []): void
|
||||
{
|
||||
$describedNamespace = $options['namespace'] ?? null;
|
||||
$description = new ApplicationDescription($application, $describedNamespace);
|
||||
$title = $this->getApplicationTitle($application);
|
||||
|
||||
$this->write($title."\n".str_repeat('=', Helper::width($title)));
|
||||
|
||||
foreach ($description->getNamespaces() as $namespace) {
|
||||
if (ApplicationDescription::GLOBAL_NAMESPACE !== $namespace['id']) {
|
||||
$this->write("\n\n");
|
||||
$this->write('**'.$namespace['id'].':**');
|
||||
}
|
||||
|
||||
$this->write("\n\n");
|
||||
$this->write(implode("\n", array_map(fn ($commandName) => \sprintf('* [`%s`](#%s)', $commandName, str_replace(':', '', $description->getCommand($commandName)->getName())), $namespace['commands'])));
|
||||
}
|
||||
|
||||
foreach ($description->getCommands() as $command) {
|
||||
$this->write("\n\n");
|
||||
$this->describeCommand($command, $options);
|
||||
}
|
||||
}
|
||||
|
||||
private function getApplicationTitle(Application $application): string
|
||||
{
|
||||
if ('UNKNOWN' !== $application->getName()) {
|
||||
if ('UNKNOWN' !== $application->getVersion()) {
|
||||
return \sprintf('%s %s', $application->getName(), $application->getVersion());
|
||||
}
|
||||
|
||||
return $application->getName();
|
||||
}
|
||||
|
||||
return 'Console Tool';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Helper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
class ReStructuredTextDescriptor extends Descriptor
|
||||
{
|
||||
// <h1>
|
||||
private string $partChar = '=';
|
||||
// <h2>
|
||||
private string $chapterChar = '-';
|
||||
// <h3>
|
||||
private string $sectionChar = '~';
|
||||
// <h4>
|
||||
private string $subsectionChar = '.';
|
||||
// <h5>
|
||||
private string $subsubsectionChar = '^';
|
||||
// <h6>
|
||||
private string $paragraphsChar = '"';
|
||||
|
||||
private array $visibleNamespaces = [];
|
||||
|
||||
public function describe(OutputInterface $output, object $object, array $options = []): void
|
||||
{
|
||||
$decorated = $output->isDecorated();
|
||||
$output->setDecorated(false);
|
||||
|
||||
parent::describe($output, $object, $options);
|
||||
|
||||
$output->setDecorated($decorated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override parent method to set $decorated = true.
|
||||
*/
|
||||
protected function write(string $content, bool $decorated = true): void
|
||||
{
|
||||
parent::write($content, $decorated);
|
||||
}
|
||||
|
||||
protected function describeInputArgument(InputArgument $argument, array $options = []): void
|
||||
{
|
||||
$this->write(
|
||||
$argument->getName() ?: '<none>'."\n".str_repeat($this->paragraphsChar, Helper::width($argument->getName()))."\n\n"
|
||||
.($argument->getDescription() ? preg_replace('/\s*[\r\n]\s*/', "\n", $argument->getDescription())."\n\n" : '')
|
||||
.'- **Is required**: '.($argument->isRequired() ? 'yes' : 'no')."\n"
|
||||
.'- **Is array**: '.($argument->isArray() ? 'yes' : 'no')."\n"
|
||||
.'- **Default**: ``'.str_replace("\n", '', var_export($argument->getDefault(), true)).'``'
|
||||
);
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option, array $options = []): void
|
||||
{
|
||||
$name = '\-\-'.$option->getName();
|
||||
if ($option->isNegatable()) {
|
||||
$name .= '|\-\-no-'.$option->getName();
|
||||
}
|
||||
if ($option->getShortcut()) {
|
||||
$name .= '|-'.str_replace('|', '|-', $option->getShortcut());
|
||||
}
|
||||
|
||||
$optionDescription = $option->getDescription() ? preg_replace('/\s*[\r\n]\s*/', "\n\n", $option->getDescription())."\n\n" : '';
|
||||
$optionDescription = (new UnicodeString($optionDescription))->ascii();
|
||||
$this->write(
|
||||
$name."\n".str_repeat($this->paragraphsChar, Helper::width($name))."\n\n"
|
||||
.$optionDescription
|
||||
.'- **Accept value**: '.($option->acceptValue() ? 'yes' : 'no')."\n"
|
||||
.'- **Is value required**: '.($option->isValueRequired() ? 'yes' : 'no')."\n"
|
||||
.'- **Is multiple**: '.($option->isArray() ? 'yes' : 'no')."\n"
|
||||
.'- **Is negatable**: '.($option->isNegatable() ? 'yes' : 'no')."\n"
|
||||
.'- **Default**: ``'.str_replace("\n", '', var_export($option->getDefault(), true)).'``'."\n"
|
||||
);
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(InputDefinition $definition, array $options = []): void
|
||||
{
|
||||
if ($showArguments = ((bool) $definition->getArguments())) {
|
||||
$this->write("Arguments\n".str_repeat($this->subsubsectionChar, 9));
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$this->write("\n\n");
|
||||
$this->describeInputArgument($argument);
|
||||
}
|
||||
}
|
||||
|
||||
if ($nonDefaultOptions = $this->getNonDefaultOptions($definition)) {
|
||||
if ($showArguments) {
|
||||
$this->write("\n\n");
|
||||
}
|
||||
|
||||
$this->write("Options\n".str_repeat($this->subsubsectionChar, 7)."\n\n");
|
||||
foreach ($nonDefaultOptions as $option) {
|
||||
$this->describeInputOption($option);
|
||||
$this->write("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeCommand(Command $command, array $options = []): void
|
||||
{
|
||||
if ($options['short'] ?? false) {
|
||||
$this->write(
|
||||
'``'.$command->getName()."``\n"
|
||||
.str_repeat($this->subsectionChar, Helper::width($command->getName()))."\n\n"
|
||||
.($command->getDescription() ? $command->getDescription()."\n\n" : '')
|
||||
."Usage\n".str_repeat($this->paragraphsChar, 5)."\n\n"
|
||||
.array_reduce($command->getAliases(), static fn ($carry, $usage) => $carry.'- ``'.$usage.'``'."\n")
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$command->mergeApplicationDefinition(false);
|
||||
|
||||
foreach ($command->getAliases() as $alias) {
|
||||
$this->write('.. _'.$alias.":\n\n");
|
||||
}
|
||||
$this->write(
|
||||
$command->getName()."\n"
|
||||
.str_repeat($this->subsectionChar, Helper::width($command->getName()))."\n\n"
|
||||
.($command->getDescription() ? $command->getDescription()."\n\n" : '')
|
||||
."Usage\n".str_repeat($this->subsubsectionChar, 5)."\n\n"
|
||||
.array_reduce(array_merge([$command->getSynopsis()], $command->getAliases(), $command->getUsages()), static fn ($carry, $usage) => $carry.'- ``'.$usage.'``'."\n")
|
||||
);
|
||||
|
||||
if ($help = $command->getProcessedHelp()) {
|
||||
$this->write("\n");
|
||||
$this->write($help);
|
||||
}
|
||||
|
||||
$definition = $command->getDefinition();
|
||||
if ($definition->getOptions() || $definition->getArguments()) {
|
||||
$this->write("\n\n");
|
||||
$this->describeInputDefinition($definition);
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeApplication(Application $application, array $options = []): void
|
||||
{
|
||||
$description = new ApplicationDescription($application, $options['namespace'] ?? null);
|
||||
$title = $this->getApplicationTitle($application);
|
||||
|
||||
$this->write($title."\n".str_repeat($this->partChar, Helper::width($title)));
|
||||
$this->createTableOfContents($description, $application);
|
||||
$this->describeCommands($application, $options);
|
||||
}
|
||||
|
||||
private function getApplicationTitle(Application $application): string
|
||||
{
|
||||
if ('UNKNOWN' === $application->getName()) {
|
||||
return 'Console Tool';
|
||||
}
|
||||
if ('UNKNOWN' !== $application->getVersion()) {
|
||||
return \sprintf('%s %s', $application->getName(), $application->getVersion());
|
||||
}
|
||||
|
||||
return $application->getName();
|
||||
}
|
||||
|
||||
private function describeCommands($application, array $options): void
|
||||
{
|
||||
$title = 'Commands';
|
||||
$this->write("\n\n$title\n".str_repeat($this->chapterChar, Helper::width($title))."\n\n");
|
||||
foreach ($this->visibleNamespaces as $namespace) {
|
||||
if ('_global' === $namespace) {
|
||||
$commands = $application->all('');
|
||||
$this->write('Global'."\n".str_repeat($this->sectionChar, Helper::width('Global'))."\n\n");
|
||||
} else {
|
||||
$commands = $application->all($namespace);
|
||||
$this->write($namespace."\n".str_repeat($this->sectionChar, Helper::width($namespace))."\n\n");
|
||||
}
|
||||
|
||||
foreach ($this->removeAliasesAndHiddenCommands($commands) as $command) {
|
||||
$this->describeCommand($command, $options);
|
||||
$this->write("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function createTableOfContents(ApplicationDescription $description, Application $application): void
|
||||
{
|
||||
$this->setVisibleNamespaces($description);
|
||||
$chapterTitle = 'Table of Contents';
|
||||
$this->write("\n\n$chapterTitle\n".str_repeat($this->chapterChar, Helper::width($chapterTitle))."\n\n");
|
||||
foreach ($this->visibleNamespaces as $namespace) {
|
||||
if ('_global' === $namespace) {
|
||||
$commands = $application->all('');
|
||||
} else {
|
||||
$commands = $application->all($namespace);
|
||||
$this->write("\n\n");
|
||||
$this->write($namespace."\n".str_repeat($this->sectionChar, Helper::width($namespace))."\n\n");
|
||||
}
|
||||
$commands = $this->removeAliasesAndHiddenCommands($commands);
|
||||
|
||||
$this->write("\n\n");
|
||||
$this->write(implode("\n", array_map(static fn ($commandName) => \sprintf('- `%s`_', $commandName), array_keys($commands))));
|
||||
}
|
||||
}
|
||||
|
||||
private function getNonDefaultOptions(InputDefinition $definition): array
|
||||
{
|
||||
$globalOptions = [
|
||||
'help',
|
||||
'silent',
|
||||
'quiet',
|
||||
'verbose',
|
||||
'version',
|
||||
'ansi',
|
||||
'no-interaction',
|
||||
];
|
||||
$nonDefaultOptions = [];
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
// Skip global options.
|
||||
if (!\in_array($option->getName(), $globalOptions, true)) {
|
||||
$nonDefaultOptions[] = $option;
|
||||
}
|
||||
}
|
||||
|
||||
return $nonDefaultOptions;
|
||||
}
|
||||
|
||||
private function setVisibleNamespaces(ApplicationDescription $description): void
|
||||
{
|
||||
$commands = $description->getCommands();
|
||||
foreach ($description->getNamespaces() as $namespace) {
|
||||
try {
|
||||
$namespaceCommands = $namespace['commands'];
|
||||
foreach ($namespaceCommands as $key => $commandName) {
|
||||
if (!\array_key_exists($commandName, $commands)) {
|
||||
// If the array key does not exist, then this is an alias.
|
||||
unset($namespaceCommands[$key]);
|
||||
} elseif ($commands[$commandName]->isHidden()) {
|
||||
unset($namespaceCommands[$key]);
|
||||
}
|
||||
}
|
||||
if (!$namespaceCommands) {
|
||||
// If the namespace contained only aliases or hidden commands, skip the namespace.
|
||||
continue;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
}
|
||||
$this->visibleNamespaces[] = $namespace['id'];
|
||||
}
|
||||
}
|
||||
|
||||
private function removeAliasesAndHiddenCommands(array $commands): array
|
||||
{
|
||||
foreach ($commands as $key => $command) {
|
||||
if ($command->isHidden() || \in_array($key, $command->getAliases(), true)) {
|
||||
unset($commands[$key]);
|
||||
}
|
||||
}
|
||||
unset($commands['completion']);
|
||||
|
||||
return $commands;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||
use Symfony\Component\Console\Helper\Helper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* Text descriptor.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TextDescriptor extends Descriptor
|
||||
{
|
||||
protected function describeInputArgument(InputArgument $argument, array $options = []): void
|
||||
{
|
||||
if (null !== $argument->getDefault() && (!\is_array($argument->getDefault()) || \count($argument->getDefault()))) {
|
||||
$default = \sprintf('<comment> [default: %s]</comment>', $this->formatDefaultValue($argument->getDefault()));
|
||||
} else {
|
||||
$default = '';
|
||||
}
|
||||
|
||||
$totalWidth = $options['total_width'] ?? Helper::width($argument->getName());
|
||||
$spacingWidth = $totalWidth - \strlen($argument->getName());
|
||||
|
||||
$this->writeText(\sprintf(' <info>%s</info> %s%s%s',
|
||||
$argument->getName(),
|
||||
str_repeat(' ', $spacingWidth),
|
||||
// + 4 = 2 spaces before <info>, 2 spaces after </info>
|
||||
preg_replace('/\s*[\r\n]\s*/', "\n".str_repeat(' ', $totalWidth + 4), $argument->getDescription()),
|
||||
$default
|
||||
), $options);
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option, array $options = []): void
|
||||
{
|
||||
if ($option->acceptValue() && null !== $option->getDefault() && (!\is_array($option->getDefault()) || \count($option->getDefault()))) {
|
||||
$default = \sprintf('<comment> [default: %s]</comment>', $this->formatDefaultValue($option->getDefault()));
|
||||
} else {
|
||||
$default = '';
|
||||
}
|
||||
|
||||
$value = '';
|
||||
if ($option->acceptValue()) {
|
||||
$value = '='.strtoupper($option->getName());
|
||||
|
||||
if ($option->isValueOptional()) {
|
||||
$value = '['.$value.']';
|
||||
}
|
||||
}
|
||||
|
||||
$totalWidth = $options['total_width'] ?? $this->calculateTotalWidthForOptions([$option]);
|
||||
$synopsis = \sprintf('%s%s',
|
||||
$option->getShortcut() ? \sprintf('-%s, ', $option->getShortcut()) : ' ',
|
||||
\sprintf($option->isNegatable() ? '--%1$s|--no-%1$s' : '--%1$s%2$s', $option->getName(), $value)
|
||||
);
|
||||
|
||||
$spacingWidth = $totalWidth - Helper::width($synopsis);
|
||||
|
||||
$this->writeText(\sprintf(' <info>%s</info> %s%s%s%s',
|
||||
$synopsis,
|
||||
str_repeat(' ', $spacingWidth),
|
||||
// + 4 = 2 spaces before <info>, 2 spaces after </info>
|
||||
preg_replace('/\s*[\r\n]\s*/', "\n".str_repeat(' ', $totalWidth + 4), $option->getDescription()),
|
||||
$default,
|
||||
$option->isArray() ? '<comment> (multiple values allowed)</comment>' : ''
|
||||
), $options);
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(InputDefinition $definition, array $options = []): void
|
||||
{
|
||||
$totalWidth = $this->calculateTotalWidthForOptions($definition->getOptions());
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$totalWidth = max($totalWidth, Helper::width($argument->getName()));
|
||||
}
|
||||
|
||||
if ($definition->getArguments()) {
|
||||
$this->writeText('<comment>Arguments:</comment>', $options);
|
||||
$this->writeText("\n");
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$this->describeInputArgument($argument, array_merge($options, ['total_width' => $totalWidth]));
|
||||
$this->writeText("\n");
|
||||
}
|
||||
}
|
||||
|
||||
if ($definition->getArguments() && $definition->getOptions()) {
|
||||
$this->writeText("\n");
|
||||
}
|
||||
|
||||
if ($definition->getOptions()) {
|
||||
$laterOptions = [];
|
||||
|
||||
$this->writeText('<comment>Options:</comment>', $options);
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
if (\strlen($option->getShortcut() ?? '') > 1) {
|
||||
$laterOptions[] = $option;
|
||||
continue;
|
||||
}
|
||||
$this->writeText("\n");
|
||||
$this->describeInputOption($option, array_merge($options, ['total_width' => $totalWidth]));
|
||||
}
|
||||
foreach ($laterOptions as $option) {
|
||||
$this->writeText("\n");
|
||||
$this->describeInputOption($option, array_merge($options, ['total_width' => $totalWidth]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeCommand(Command $command, array $options = []): void
|
||||
{
|
||||
$command->mergeApplicationDefinition(false);
|
||||
|
||||
if ($description = $command->getDescription()) {
|
||||
$this->writeText('<comment>Description:</comment>', $options);
|
||||
$this->writeText("\n");
|
||||
$this->writeText(' '.$description);
|
||||
$this->writeText("\n\n");
|
||||
}
|
||||
|
||||
$this->writeText('<comment>Usage:</comment>', $options);
|
||||
foreach (array_merge([$command->getSynopsis(true)], $command->getAliases(), $command->getUsages()) as $usage) {
|
||||
$this->writeText("\n");
|
||||
$this->writeText(' '.OutputFormatter::escape($usage), $options);
|
||||
}
|
||||
$this->writeText("\n");
|
||||
|
||||
$definition = $command->getDefinition();
|
||||
if ($definition->getOptions() || $definition->getArguments()) {
|
||||
$this->writeText("\n");
|
||||
$this->describeInputDefinition($definition, $options);
|
||||
$this->writeText("\n");
|
||||
}
|
||||
|
||||
$help = $command->getProcessedHelp();
|
||||
if ($help && $help !== $description) {
|
||||
$this->writeText("\n");
|
||||
$this->writeText('<comment>Help:</comment>', $options);
|
||||
$this->writeText("\n");
|
||||
$this->writeText(' '.str_replace("\n", "\n ", $help), $options);
|
||||
$this->writeText("\n");
|
||||
}
|
||||
}
|
||||
|
||||
protected function describeApplication(Application $application, array $options = []): void
|
||||
{
|
||||
$describedNamespace = $options['namespace'] ?? null;
|
||||
$description = new ApplicationDescription($application, $describedNamespace);
|
||||
|
||||
if (isset($options['raw_text']) && $options['raw_text']) {
|
||||
$width = $this->getColumnWidth($description->getCommands());
|
||||
|
||||
foreach ($description->getCommands() as $command) {
|
||||
$this->writeText(\sprintf("%-{$width}s %s", $command->getName(), $command->getDescription()), $options);
|
||||
$this->writeText("\n");
|
||||
}
|
||||
} else {
|
||||
if ('' != $help = $application->getHelp()) {
|
||||
$this->writeText("$help\n\n", $options);
|
||||
}
|
||||
|
||||
$this->writeText("<comment>Usage:</comment>\n", $options);
|
||||
$this->writeText(" command [options] [arguments]\n\n", $options);
|
||||
|
||||
$this->describeInputDefinition(new InputDefinition($application->getDefinition()->getOptions()), $options);
|
||||
|
||||
$this->writeText("\n");
|
||||
$this->writeText("\n");
|
||||
|
||||
$commands = $description->getCommands();
|
||||
$namespaces = $description->getNamespaces();
|
||||
if ($describedNamespace && $namespaces) {
|
||||
// make sure all alias commands are included when describing a specific namespace
|
||||
$describedNamespaceInfo = reset($namespaces);
|
||||
foreach ($describedNamespaceInfo['commands'] as $name) {
|
||||
$commands[$name] = $description->getCommand($name);
|
||||
}
|
||||
}
|
||||
|
||||
// calculate max. width based on available commands per namespace
|
||||
$width = $this->getColumnWidth(array_merge(...array_values(array_map(fn ($namespace) => array_intersect($namespace['commands'], array_keys($commands)), array_values($namespaces)))));
|
||||
|
||||
if ($describedNamespace) {
|
||||
$this->writeText(\sprintf('<comment>Available commands for the "%s" namespace:</comment>', $describedNamespace), $options);
|
||||
} else {
|
||||
$this->writeText('<comment>Available commands:</comment>', $options);
|
||||
}
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
$namespace['commands'] = array_filter($namespace['commands'], fn ($name) => isset($commands[$name]));
|
||||
|
||||
if (!$namespace['commands']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$describedNamespace && ApplicationDescription::GLOBAL_NAMESPACE !== $namespace['id']) {
|
||||
$this->writeText("\n");
|
||||
$this->writeText(' <comment>'.$namespace['id'].'</comment>', $options);
|
||||
}
|
||||
|
||||
foreach ($namespace['commands'] as $name) {
|
||||
$this->writeText("\n");
|
||||
$spacingWidth = $width - Helper::width($name);
|
||||
$command = $commands[$name];
|
||||
$commandAliases = $name === $command->getName() ? $this->getCommandAliasesText($command) : '';
|
||||
$this->writeText(\sprintf(' <info>%s</info>%s%s', $name, str_repeat(' ', $spacingWidth), $commandAliases.$command->getDescription()), $options);
|
||||
}
|
||||
}
|
||||
|
||||
$this->writeText("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private function writeText(string $content, array $options = []): void
|
||||
{
|
||||
$this->write(
|
||||
isset($options['raw_text']) && $options['raw_text'] ? strip_tags($content) : $content,
|
||||
isset($options['raw_output']) ? !$options['raw_output'] : true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats command aliases to show them in the command description.
|
||||
*/
|
||||
private function getCommandAliasesText(Command $command): string
|
||||
{
|
||||
$text = '';
|
||||
$aliases = $command->getAliases();
|
||||
|
||||
if ($aliases) {
|
||||
$text = '['.implode('|', $aliases).'] ';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats input option/argument default value.
|
||||
*/
|
||||
private function formatDefaultValue(mixed $default): string
|
||||
{
|
||||
if (\INF === $default) {
|
||||
return 'INF';
|
||||
}
|
||||
|
||||
if (\is_string($default)) {
|
||||
$default = OutputFormatter::escape($default);
|
||||
} elseif (\is_array($default)) {
|
||||
foreach ($default as $key => $value) {
|
||||
if (\is_string($value)) {
|
||||
$default[$key] = OutputFormatter::escape($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('\\\\', '\\', json_encode($default, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Command|string> $commands
|
||||
*/
|
||||
private function getColumnWidth(array $commands): int
|
||||
{
|
||||
$widths = [];
|
||||
|
||||
foreach ($commands as $command) {
|
||||
if ($command instanceof Command) {
|
||||
$widths[] = Helper::width($command->getName());
|
||||
foreach ($command->getAliases() as $alias) {
|
||||
$widths[] = Helper::width($alias);
|
||||
}
|
||||
} else {
|
||||
$widths[] = Helper::width($command);
|
||||
}
|
||||
}
|
||||
|
||||
return $widths ? max($widths) + 2 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputOption[] $options
|
||||
*/
|
||||
private function calculateTotalWidthForOptions(array $options): int
|
||||
{
|
||||
$totalWidth = 0;
|
||||
foreach ($options as $option) {
|
||||
// "-" + shortcut + ", --" + name
|
||||
$nameLength = 1 + max(Helper::width($option->getShortcut()), 1) + 4 + Helper::width($option->getName());
|
||||
if ($option->isNegatable()) {
|
||||
$nameLength += 6 + Helper::width($option->getName()); // |--no- + name
|
||||
} elseif ($option->acceptValue()) {
|
||||
$valueLength = 1 + Helper::width($option->getName()); // = + value
|
||||
$valueLength += $option->isValueOptional() ? 2 : 0; // [ + ]
|
||||
|
||||
$nameLength += $valueLength;
|
||||
}
|
||||
$totalWidth = max($totalWidth, $nameLength);
|
||||
}
|
||||
|
||||
return $totalWidth;
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
<?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\Descriptor;
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* XML descriptor.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class XmlDescriptor extends Descriptor
|
||||
{
|
||||
public function getInputDefinitionDocument(InputDefinition $definition): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->appendChild($definitionXML = $dom->createElement('definition'));
|
||||
|
||||
$definitionXML->appendChild($argumentsXML = $dom->createElement('arguments'));
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$this->appendDocument($argumentsXML, $this->getInputArgumentDocument($argument));
|
||||
}
|
||||
|
||||
$definitionXML->appendChild($optionsXML = $dom->createElement('options'));
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
$this->appendDocument($optionsXML, $this->getInputOptionDocument($option));
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
public function getCommandDocument(Command $command, bool $short = false): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->appendChild($commandXML = $dom->createElement('command'));
|
||||
|
||||
$commandXML->setAttribute('id', $command->getName());
|
||||
$commandXML->setAttribute('name', $command->getName());
|
||||
$commandXML->setAttribute('hidden', $command->isHidden() ? 1 : 0);
|
||||
|
||||
$commandXML->appendChild($usagesXML = $dom->createElement('usages'));
|
||||
|
||||
$commandXML->appendChild($descriptionXML = $dom->createElement('description'));
|
||||
$descriptionXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getDescription())));
|
||||
|
||||
if ($short) {
|
||||
foreach ($command->getAliases() as $usage) {
|
||||
$usagesXML->appendChild($dom->createElement('usage', $usage));
|
||||
}
|
||||
} else {
|
||||
$command->mergeApplicationDefinition(false);
|
||||
|
||||
foreach (array_merge([$command->getSynopsis()], $command->getAliases(), $command->getUsages()) as $usage) {
|
||||
$usagesXML->appendChild($dom->createElement('usage', $usage));
|
||||
}
|
||||
|
||||
$commandXML->appendChild($helpXML = $dom->createElement('help'));
|
||||
$helpXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getProcessedHelp())));
|
||||
|
||||
$definitionXML = $this->getInputDefinitionDocument($command->getDefinition());
|
||||
$this->appendDocument($commandXML, $definitionXML->getElementsByTagName('definition')->item(0));
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
public function getApplicationDocument(Application $application, ?string $namespace = null, bool $short = false): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$dom->appendChild($rootXml = $dom->createElement('symfony'));
|
||||
|
||||
if ('UNKNOWN' !== $application->getName()) {
|
||||
$rootXml->setAttribute('name', $application->getName());
|
||||
if ('UNKNOWN' !== $application->getVersion()) {
|
||||
$rootXml->setAttribute('version', $application->getVersion());
|
||||
}
|
||||
}
|
||||
|
||||
$rootXml->appendChild($commandsXML = $dom->createElement('commands'));
|
||||
|
||||
$description = new ApplicationDescription($application, $namespace, true);
|
||||
|
||||
if ($namespace) {
|
||||
$commandsXML->setAttribute('namespace', $namespace);
|
||||
}
|
||||
|
||||
foreach ($description->getCommands() as $command) {
|
||||
$this->appendDocument($commandsXML, $this->getCommandDocument($command, $short));
|
||||
}
|
||||
|
||||
if (!$namespace) {
|
||||
$rootXml->appendChild($namespacesXML = $dom->createElement('namespaces'));
|
||||
|
||||
foreach ($description->getNamespaces() as $namespaceDescription) {
|
||||
$namespacesXML->appendChild($namespaceArrayXML = $dom->createElement('namespace'));
|
||||
$namespaceArrayXML->setAttribute('id', $namespaceDescription['id']);
|
||||
|
||||
foreach ($namespaceDescription['commands'] as $name) {
|
||||
$namespaceArrayXML->appendChild($commandXML = $dom->createElement('command'));
|
||||
$commandXML->appendChild($dom->createTextNode($name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
protected function describeInputArgument(InputArgument $argument, array $options = []): void
|
||||
{
|
||||
$this->writeDocument($this->getInputArgumentDocument($argument));
|
||||
}
|
||||
|
||||
protected function describeInputOption(InputOption $option, array $options = []): void
|
||||
{
|
||||
$this->writeDocument($this->getInputOptionDocument($option));
|
||||
}
|
||||
|
||||
protected function describeInputDefinition(InputDefinition $definition, array $options = []): void
|
||||
{
|
||||
$this->writeDocument($this->getInputDefinitionDocument($definition));
|
||||
}
|
||||
|
||||
protected function describeCommand(Command $command, array $options = []): void
|
||||
{
|
||||
$this->writeDocument($this->getCommandDocument($command, $options['short'] ?? false));
|
||||
}
|
||||
|
||||
protected function describeApplication(Application $application, array $options = []): void
|
||||
{
|
||||
$this->writeDocument($this->getApplicationDocument($application, $options['namespace'] ?? null, $options['short'] ?? false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends document children to parent node.
|
||||
*/
|
||||
private function appendDocument(\DOMNode $parentNode, \DOMNode $importedParent): void
|
||||
{
|
||||
foreach ($importedParent->childNodes as $childNode) {
|
||||
$parentNode->appendChild($parentNode->ownerDocument->importNode($childNode, true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes DOM document.
|
||||
*/
|
||||
private function writeDocument(\DOMDocument $dom): void
|
||||
{
|
||||
$dom->formatOutput = true;
|
||||
$this->write($dom->saveXML());
|
||||
}
|
||||
|
||||
private function getInputArgumentDocument(InputArgument $argument): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
$dom->appendChild($objectXML = $dom->createElement('argument'));
|
||||
$objectXML->setAttribute('name', $argument->getName());
|
||||
$objectXML->setAttribute('is_required', $argument->isRequired() ? 1 : 0);
|
||||
$objectXML->setAttribute('is_array', $argument->isArray() ? 1 : 0);
|
||||
$objectXML->appendChild($descriptionXML = $dom->createElement('description'));
|
||||
$descriptionXML->appendChild($dom->createTextNode($argument->getDescription()));
|
||||
|
||||
$objectXML->appendChild($defaultsXML = $dom->createElement('defaults'));
|
||||
$defaults = \is_array($argument->getDefault()) ? $argument->getDefault() : (\is_bool($argument->getDefault()) ? [var_export($argument->getDefault(), true)] : ($argument->getDefault() ? [$argument->getDefault()] : []));
|
||||
foreach ($defaults as $default) {
|
||||
$defaultsXML->appendChild($defaultXML = $dom->createElement('default'));
|
||||
$defaultXML->appendChild($dom->createTextNode($default));
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
private function getInputOptionDocument(InputOption $option): \DOMDocument
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
$dom->appendChild($objectXML = $dom->createElement('option'));
|
||||
$objectXML->setAttribute('name', '--'.$option->getName());
|
||||
$pos = strpos($option->getShortcut() ?? '', '|');
|
||||
if (false !== $pos) {
|
||||
$objectXML->setAttribute('shortcut', '-'.substr($option->getShortcut(), 0, $pos));
|
||||
$objectXML->setAttribute('shortcuts', '-'.str_replace('|', '|-', $option->getShortcut()));
|
||||
} else {
|
||||
$objectXML->setAttribute('shortcut', $option->getShortcut() ? '-'.$option->getShortcut() : '');
|
||||
}
|
||||
$objectXML->setAttribute('accept_value', $option->acceptValue() ? 1 : 0);
|
||||
$objectXML->setAttribute('is_value_required', $option->isValueRequired() ? 1 : 0);
|
||||
$objectXML->setAttribute('is_multiple', $option->isArray() ? 1 : 0);
|
||||
$objectXML->appendChild($descriptionXML = $dom->createElement('description'));
|
||||
$descriptionXML->appendChild($dom->createTextNode($option->getDescription()));
|
||||
|
||||
if ($option->acceptValue()) {
|
||||
$defaults = \is_array($option->getDefault()) ? $option->getDefault() : (\is_bool($option->getDefault()) ? [var_export($option->getDefault(), true)] : ($option->getDefault() ? [$option->getDefault()] : []));
|
||||
$objectXML->appendChild($defaultsXML = $dom->createElement('defaults'));
|
||||
|
||||
foreach ($defaults as $default) {
|
||||
$defaultsXML->appendChild($defaultXML = $dom->createElement('default'));
|
||||
$defaultXML->appendChild($dom->createTextNode($default));
|
||||
}
|
||||
}
|
||||
|
||||
if ($option->isNegatable()) {
|
||||
$dom->appendChild($objectXML = $dom->createElement('option'));
|
||||
$objectXML->setAttribute('name', '--no-'.$option->getName());
|
||||
$objectXML->setAttribute('shortcut', '');
|
||||
$objectXML->setAttribute('accept_value', 0);
|
||||
$objectXML->setAttribute('is_value_required', 0);
|
||||
$objectXML->setAttribute('is_multiple', 0);
|
||||
$objectXML->appendChild($descriptionXML = $dom->createElement('description'));
|
||||
$descriptionXML->appendChild($dom->createTextNode('Negate the "--'.$option->getName().'" option'));
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class ConsoleAlarmEvent extends ConsoleEvent
|
||||
{
|
||||
public function __construct(
|
||||
Command $command,
|
||||
InputInterface $input,
|
||||
OutputInterface $output,
|
||||
private int|false $exitCode = 0,
|
||||
) {
|
||||
parent::__construct($command, $input, $output);
|
||||
}
|
||||
|
||||
public function setExitCode(int $exitCode): void
|
||||
{
|
||||
if ($exitCode < 0 || $exitCode > 255) {
|
||||
throw new \InvalidArgumentException('Exit code must be between 0 and 255.');
|
||||
}
|
||||
|
||||
$this->exitCode = $exitCode;
|
||||
}
|
||||
|
||||
public function abortExit(): void
|
||||
{
|
||||
$this->exitCode = false;
|
||||
}
|
||||
|
||||
public function getExitCode(): int|false
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\Event;
|
||||
|
||||
/**
|
||||
* Allows to do things before the command is executed, like skipping the command or executing code before the command is
|
||||
* going to be executed.
|
||||
*
|
||||
* Changing the input arguments will have no effect.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
final class ConsoleCommandEvent extends ConsoleEvent
|
||||
{
|
||||
/**
|
||||
* The return code for skipped commands, this will also be passed into the terminate event.
|
||||
*/
|
||||
public const RETURN_CODE_DISABLED = 113;
|
||||
|
||||
/**
|
||||
* Indicates if the command should be run or skipped.
|
||||
*/
|
||||
private bool $commandShouldRun = true;
|
||||
|
||||
/**
|
||||
* Disables the command, so it won't be run.
|
||||
*/
|
||||
public function disableCommand(): bool
|
||||
{
|
||||
return $this->commandShouldRun = false;
|
||||
}
|
||||
|
||||
public function enableCommand(): bool
|
||||
{
|
||||
return $this->commandShouldRun = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the command is runnable, false otherwise.
|
||||
*/
|
||||
public function commandShouldRun(): bool
|
||||
{
|
||||
return $this->commandShouldRun;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Allows to handle throwables thrown while running a command.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
final class ConsoleErrorEvent extends ConsoleEvent
|
||||
{
|
||||
private int $exitCode;
|
||||
|
||||
public function __construct(
|
||||
InputInterface $input,
|
||||
OutputInterface $output,
|
||||
private \Throwable $error,
|
||||
?Command $command = null,
|
||||
) {
|
||||
parent::__construct($command, $input, $output);
|
||||
}
|
||||
|
||||
public function getError(): \Throwable
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
public function setError(\Throwable $error): void
|
||||
{
|
||||
$this->error = $error;
|
||||
}
|
||||
|
||||
public function setExitCode(int $exitCode): void
|
||||
{
|
||||
$this->exitCode = $exitCode;
|
||||
|
||||
$r = new \ReflectionProperty($this->error, 'code');
|
||||
$r->setValue($this->error, $this->exitCode);
|
||||
}
|
||||
|
||||
public function getExitCode(): int
|
||||
{
|
||||
return $this->exitCode ?? (\is_int($this->error->getCode()) && 0 !== $this->error->getCode() ? $this->error->getCode() : 1);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Allows to inspect input and output of a command.
|
||||
*
|
||||
* @author Francesco Levorato <git@flevour.net>
|
||||
*/
|
||||
class ConsoleEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
protected ?Command $command,
|
||||
private InputInterface $input,
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the command that is executed.
|
||||
*/
|
||||
public function getCommand(): ?Command
|
||||
{
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input instance.
|
||||
*/
|
||||
public function getInput(): InputInterface
|
||||
{
|
||||
return $this->input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the output instance.
|
||||
*/
|
||||
public function getOutput(): OutputInterface
|
||||
{
|
||||
return $this->output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author marie <marie@users.noreply.github.com>
|
||||
*/
|
||||
final class ConsoleSignalEvent extends ConsoleEvent
|
||||
{
|
||||
public function __construct(
|
||||
Command $command,
|
||||
InputInterface $input,
|
||||
OutputInterface $output,
|
||||
private int $handlingSignal,
|
||||
private int|false $exitCode = 0,
|
||||
) {
|
||||
parent::__construct($command, $input, $output);
|
||||
}
|
||||
|
||||
public function getHandlingSignal(): int
|
||||
{
|
||||
return $this->handlingSignal;
|
||||
}
|
||||
|
||||
public function setExitCode(int $exitCode): void
|
||||
{
|
||||
if ($exitCode < 0 || $exitCode > 255) {
|
||||
throw new \InvalidArgumentException('Exit code must be between 0 and 255.');
|
||||
}
|
||||
|
||||
$this->exitCode = $exitCode;
|
||||
}
|
||||
|
||||
public function abortExit(): void
|
||||
{
|
||||
$this->exitCode = false;
|
||||
}
|
||||
|
||||
public function getExitCode(): int|false
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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\Event;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Allows to manipulate the exit code of a command after its execution.
|
||||
*
|
||||
* @author Francesco Levorato <git@flevour.net>
|
||||
* @author Jules Pietri <jules@heahprod.com>
|
||||
*/
|
||||
final class ConsoleTerminateEvent extends ConsoleEvent
|
||||
{
|
||||
public function __construct(
|
||||
Command $command,
|
||||
InputInterface $input,
|
||||
OutputInterface $output,
|
||||
private int $exitCode,
|
||||
private readonly ?int $interruptingSignal = null,
|
||||
) {
|
||||
parent::__construct($command, $input, $output);
|
||||
}
|
||||
|
||||
public function setExitCode(int $exitCode): void
|
||||
{
|
||||
$this->exitCode = $exitCode;
|
||||
}
|
||||
|
||||
public function getExitCode(): int
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
public function getInterruptingSignal(): ?int
|
||||
{
|
||||
return $this->interruptingSignal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\ConsoleEvents;
|
||||
use Symfony\Component\Console\Event\ConsoleErrorEvent;
|
||||
use Symfony\Component\Console\Event\ConsoleEvent;
|
||||
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
/**
|
||||
* @author James Halsall <james.t.halsall@googlemail.com>
|
||||
* @author Robin Chalas <robin.chalas@gmail.com>
|
||||
*/
|
||||
class ErrorListener implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ?LoggerInterface $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onConsoleError(ConsoleErrorEvent $event): void
|
||||
{
|
||||
if (null === $this->logger) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = $event->getError();
|
||||
|
||||
if (!$inputString = self::getInputString($event)) {
|
||||
$this->logger->critical('An error occurred while using the console. Message: "{message}"', ['exception' => $error, 'message' => $error->getMessage()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->critical('Error thrown while running command "{command}". Message: "{message}"', ['exception' => $error, 'command' => $inputString, 'message' => $error->getMessage()]);
|
||||
}
|
||||
|
||||
public function onConsoleTerminate(ConsoleTerminateEvent $event): void
|
||||
{
|
||||
if (null === $this->logger) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exitCode = $event->getExitCode();
|
||||
|
||||
if (0 === $exitCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$inputString = self::getInputString($event)) {
|
||||
$this->logger->debug('The console exited with code "{code}"', ['code' => $exitCode]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->debug('Command "{command}" exited with code "{code}"', ['command' => $inputString, 'code' => $exitCode]);
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ConsoleEvents::ERROR => ['onConsoleError', -128],
|
||||
ConsoleEvents::TERMINATE => ['onConsoleTerminate', -128],
|
||||
];
|
||||
}
|
||||
|
||||
private static function getInputString(ConsoleEvent $event): string
|
||||
{
|
||||
$commandName = $event->getCommand()?->getName();
|
||||
$inputString = (string) $event->getInput();
|
||||
|
||||
if ($commandName) {
|
||||
return str_replace(["'$commandName'", "\"$commandName\""], $commandName, $inputString);
|
||||
}
|
||||
|
||||
return $inputString;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Represents an incorrect command name typed in the console.
|
||||
*
|
||||
* @author Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
class CommandNotFoundException extends \InvalidArgumentException implements ExceptionInterface
|
||||
{
|
||||
/**
|
||||
* @param string $message Exception message to throw
|
||||
* @param string[] $alternatives List of similar defined names
|
||||
* @param int $code Exception code
|
||||
* @param \Throwable|null $previous Previous exception used for the exception chaining
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
private array $alternatives = [],
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAlternatives(): array
|
||||
{
|
||||
return $this->alternatives;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* ExceptionInterface.
|
||||
*
|
||||
* @author Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
interface ExceptionInterface extends \Throwable
|
||||
{
|
||||
}
|
||||
@@ -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 Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Represents an incorrect option name or value typed in the console.
|
||||
*
|
||||
* @author Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
class InvalidOptionException extends \InvalidArgumentException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -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 Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
class LogicException extends \LogicException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Represents failure to read input from stdin.
|
||||
*
|
||||
* @author Gabriel Ostrolucký <gabriel.ostrolucky@gmail.com>
|
||||
*/
|
||||
class MissingInputException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Represents an incorrect namespace typed in the console.
|
||||
*
|
||||
* @author Pierre du Plessis <pdples@gmail.com>
|
||||
*/
|
||||
class NamespaceNotFoundException extends CommandNotFoundException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\Console\Messenger\RunCommandContext;
|
||||
|
||||
/**
|
||||
* @author Kevin Bond <kevinbond@gmail.com>
|
||||
*/
|
||||
final class RunCommandFailedException extends RuntimeException
|
||||
{
|
||||
public function __construct(\Throwable|string $exception, public readonly RunCommandContext $context)
|
||||
{
|
||||
parent::__construct(
|
||||
$exception instanceof \Throwable ? $exception->getMessage() : $exception,
|
||||
$exception instanceof \Throwable ? $exception->getCode() : 0,
|
||||
$exception instanceof \Throwable ? $exception : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
class RuntimeException extends \RuntimeException implements ExceptionInterface
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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\Formatter;
|
||||
|
||||
/**
|
||||
* @author Tien Xuan Vo <tien.xuan.vo@gmail.com>
|
||||
*/
|
||||
final class NullOutputFormatter implements OutputFormatterInterface
|
||||
{
|
||||
private NullOutputFormatterStyle $style;
|
||||
|
||||
public function format(?string $message): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getStyle(string $name): OutputFormatterStyleInterface
|
||||
{
|
||||
// to comply with the interface we must return a OutputFormatterStyleInterface
|
||||
return $this->style ??= new NullOutputFormatterStyle();
|
||||
}
|
||||
|
||||
public function hasStyle(string $name): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isDecorated(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function setDecorated(bool $decorated): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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\Formatter;
|
||||
|
||||
/**
|
||||
* @author Tien Xuan Vo <tien.xuan.vo@gmail.com>
|
||||
*/
|
||||
final class NullOutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
{
|
||||
public function apply(string $text): string
|
||||
{
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function setBackground(?string $color): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public function setForeground(?string $color): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public function setOption(string $option): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public function setOptions(array $options): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public function unsetOption(string $option): void
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?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\Formatter;
|
||||
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Helper\Helper;
|
||||
|
||||
use function Symfony\Component\String\b;
|
||||
|
||||
/**
|
||||
* Formatter class for console output.
|
||||
*
|
||||
* @author Konstantin Kudryashov <ever.zet@gmail.com>
|
||||
* @author Roland Franssen <franssen.roland@gmail.com>
|
||||
*/
|
||||
class OutputFormatter implements WrappableOutputFormatterInterface
|
||||
{
|
||||
private array $styles = [];
|
||||
private OutputFormatterStyleStack $styleStack;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->styleStack = clone $this->styleStack;
|
||||
foreach ($this->styles as $key => $value) {
|
||||
$this->styles[$key] = clone $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes "<" and ">" special chars in given text.
|
||||
*/
|
||||
public static function escape(string $text): string
|
||||
{
|
||||
$text = preg_replace('/([^\\\\]|^)([<>])/', '$1\\\\$2', $text);
|
||||
|
||||
return self::escapeTrailingBackslash($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes trailing "\" in given text.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function escapeTrailingBackslash(string $text): string
|
||||
{
|
||||
if (str_ends_with($text, '\\')) {
|
||||
$len = \strlen($text);
|
||||
$text = rtrim($text, '\\');
|
||||
$text = str_replace("\0", '', $text);
|
||||
$text .= str_repeat("\0", $len - \strlen($text));
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes console output formatter.
|
||||
*
|
||||
* @param OutputFormatterStyleInterface[] $styles Array of "name => FormatterStyle" instances
|
||||
*/
|
||||
public function __construct(
|
||||
private bool $decorated = false,
|
||||
array $styles = [],
|
||||
) {
|
||||
$this->setStyle('error', new OutputFormatterStyle('white', 'red'));
|
||||
$this->setStyle('info', new OutputFormatterStyle('green'));
|
||||
$this->setStyle('comment', new OutputFormatterStyle('yellow'));
|
||||
$this->setStyle('question', new OutputFormatterStyle('black', 'cyan'));
|
||||
|
||||
foreach ($styles as $name => $style) {
|
||||
$this->setStyle($name, $style);
|
||||
}
|
||||
|
||||
$this->styleStack = new OutputFormatterStyleStack();
|
||||
}
|
||||
|
||||
public function setDecorated(bool $decorated): void
|
||||
{
|
||||
$this->decorated = $decorated;
|
||||
}
|
||||
|
||||
public function isDecorated(): bool
|
||||
{
|
||||
return $this->decorated;
|
||||
}
|
||||
|
||||
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
|
||||
{
|
||||
$this->styles[strtolower($name)] = $style;
|
||||
}
|
||||
|
||||
public function hasStyle(string $name): bool
|
||||
{
|
||||
return isset($this->styles[strtolower($name)]);
|
||||
}
|
||||
|
||||
public function getStyle(string $name): OutputFormatterStyleInterface
|
||||
{
|
||||
if (!$this->hasStyle($name)) {
|
||||
throw new InvalidArgumentException(\sprintf('Undefined style: "%s".', $name));
|
||||
}
|
||||
|
||||
return $this->styles[strtolower($name)];
|
||||
}
|
||||
|
||||
public function format(?string $message): ?string
|
||||
{
|
||||
return $this->formatAndWrap($message, 0);
|
||||
}
|
||||
|
||||
public function formatAndWrap(?string $message, int $width): string
|
||||
{
|
||||
if (null === $message) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
$output = '';
|
||||
$openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
|
||||
$closeTagRegex = '[a-z][^<>]*+';
|
||||
$currentLineLength = 0;
|
||||
preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[0] as $i => $match) {
|
||||
$pos = $match[1];
|
||||
$text = $match[0];
|
||||
|
||||
if (0 != $pos && '\\' == $message[$pos - 1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// convert byte position to character position.
|
||||
$pos = Helper::length(substr($message, 0, $pos));
|
||||
// add the text up to the next tag
|
||||
$output .= $this->applyCurrentStyle(Helper::substr($message, $offset, $pos - $offset), $output, $width, $currentLineLength);
|
||||
$offset = $pos + Helper::length($text);
|
||||
|
||||
// opening tag?
|
||||
if ($open = '/' !== $text[1]) {
|
||||
$tag = $matches[1][$i][0];
|
||||
} else {
|
||||
$tag = $matches[3][$i][0] ?? '';
|
||||
}
|
||||
|
||||
if (!$open && !$tag) {
|
||||
// </>
|
||||
$this->styleStack->pop();
|
||||
} elseif (null === $style = $this->createStyleFromString($tag)) {
|
||||
$output .= $this->applyCurrentStyle($text, $output, $width, $currentLineLength);
|
||||
} elseif ($open) {
|
||||
$this->styleStack->push($style);
|
||||
} else {
|
||||
$this->styleStack->pop($style);
|
||||
}
|
||||
}
|
||||
|
||||
$output .= $this->applyCurrentStyle(Helper::substr($message, $offset), $output, $width, $currentLineLength);
|
||||
|
||||
return strtr($output, ["\0" => '\\', '\\<' => '<', '\\>' => '>']);
|
||||
}
|
||||
|
||||
public function getStyleStack(): OutputFormatterStyleStack
|
||||
{
|
||||
return $this->styleStack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to create new style instance from string.
|
||||
*/
|
||||
private function createStyleFromString(string $string): ?OutputFormatterStyleInterface
|
||||
{
|
||||
if (isset($this->styles[$string])) {
|
||||
return $this->styles[$string];
|
||||
}
|
||||
|
||||
if (!preg_match_all('/([^=]+)=([^;]+)(;|$)/', $string, $matches, \PREG_SET_ORDER)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$style = new OutputFormatterStyle();
|
||||
foreach ($matches as $match) {
|
||||
array_shift($match);
|
||||
$match[0] = strtolower($match[0]);
|
||||
|
||||
if ('fg' == $match[0]) {
|
||||
$style->setForeground(strtolower($match[1]));
|
||||
} elseif ('bg' == $match[0]) {
|
||||
$style->setBackground(strtolower($match[1]));
|
||||
} elseif ('href' === $match[0]) {
|
||||
$url = preg_replace('{\\\\([<>])}', '$1', $match[1]);
|
||||
$style->setHref($url);
|
||||
} elseif ('options' === $match[0]) {
|
||||
preg_match_all('([^,;]+)', strtolower($match[1]), $options);
|
||||
$options = array_shift($options);
|
||||
foreach ($options as $option) {
|
||||
$style->setOption($option);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies current style from stack to text, if must be applied.
|
||||
*/
|
||||
private function applyCurrentStyle(string $text, string $current, int $width, int &$currentLineLength): string
|
||||
{
|
||||
if ('' === $text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$width) {
|
||||
return $this->isDecorated() ? $this->styleStack->getCurrent()->apply($text) : $text;
|
||||
}
|
||||
|
||||
if (!$currentLineLength && '' !== $current) {
|
||||
$text = ltrim($text);
|
||||
}
|
||||
|
||||
if ($currentLineLength) {
|
||||
$lines = explode("\n", $text, 2);
|
||||
$prefix = Helper::substr($lines[0], 0, $i = $width - $currentLineLength)."\n";
|
||||
$text = Helper::substr($lines[0], $i);
|
||||
|
||||
if (isset($lines[1])) {
|
||||
// $prefix may contain the full first line in which the \n is already a part of $prefix.
|
||||
if ('' !== $text) {
|
||||
$text .= "\n";
|
||||
}
|
||||
|
||||
$text .= $lines[1];
|
||||
}
|
||||
} else {
|
||||
$prefix = '';
|
||||
}
|
||||
|
||||
preg_match('~(\\n)$~', $text, $matches);
|
||||
$text = $prefix.$this->addLineBreaks($text, $width);
|
||||
$text = rtrim($text, "\n").($matches[1] ?? '');
|
||||
|
||||
if (!$currentLineLength && '' !== $current && !str_ends_with($current, "\n")) {
|
||||
$text = "\n".$text;
|
||||
}
|
||||
|
||||
$lines = explode("\n", $text);
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$currentLineLength = 0 === $i ? $currentLineLength + Helper::length($line) : Helper::length($line);
|
||||
if ($width <= $currentLineLength) {
|
||||
$currentLineLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isDecorated()) {
|
||||
foreach ($lines as $i => $line) {
|
||||
$lines[$i] = $this->styleStack->getCurrent()->apply($line);
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function addLineBreaks(string $text, int $width): string
|
||||
{
|
||||
$encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8';
|
||||
|
||||
return b($text)->toUnicodeString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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\Formatter;
|
||||
|
||||
/**
|
||||
* Formatter interface for console output.
|
||||
*
|
||||
* @author Konstantin Kudryashov <ever.zet@gmail.com>
|
||||
*/
|
||||
interface OutputFormatterInterface
|
||||
{
|
||||
/**
|
||||
* Sets the decorated flag.
|
||||
*/
|
||||
public function setDecorated(bool $decorated): void;
|
||||
|
||||
/**
|
||||
* Whether the output will decorate messages.
|
||||
*/
|
||||
public function isDecorated(): bool;
|
||||
|
||||
/**
|
||||
* Sets a new style.
|
||||
*/
|
||||
public function setStyle(string $name, OutputFormatterStyleInterface $style): void;
|
||||
|
||||
/**
|
||||
* Checks if output formatter has style with specified name.
|
||||
*/
|
||||
public function hasStyle(string $name): bool;
|
||||
|
||||
/**
|
||||
* Gets style options from style with specified name.
|
||||
*
|
||||
* @throws \InvalidArgumentException When style isn't defined
|
||||
*/
|
||||
public function getStyle(string $name): OutputFormatterStyleInterface;
|
||||
|
||||
/**
|
||||
* Formats a message according to the given styles.
|
||||
*/
|
||||
public function format(?string $message): ?string;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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\Formatter;
|
||||
|
||||
use Symfony\Component\Console\Color;
|
||||
|
||||
/**
|
||||
* Formatter style class for defining styles.
|
||||
*
|
||||
* @author Konstantin Kudryashov <ever.zet@gmail.com>
|
||||
*/
|
||||
class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
{
|
||||
private Color $color;
|
||||
private string $foreground;
|
||||
private string $background;
|
||||
private array $options;
|
||||
private ?string $href = null;
|
||||
private bool $handlesHrefGracefully;
|
||||
|
||||
/**
|
||||
* Initializes output formatter style.
|
||||
*
|
||||
* @param string|null $foreground The style foreground color name
|
||||
* @param string|null $background The style background color name
|
||||
*/
|
||||
public function __construct(?string $foreground = null, ?string $background = null, array $options = [])
|
||||
{
|
||||
$this->color = new Color($this->foreground = $foreground ?: '', $this->background = $background ?: '', $this->options = $options);
|
||||
}
|
||||
|
||||
public function setForeground(?string $color): void
|
||||
{
|
||||
$this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options);
|
||||
}
|
||||
|
||||
public function setBackground(?string $color): void
|
||||
{
|
||||
$this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options);
|
||||
}
|
||||
|
||||
public function setHref(string $url): void
|
||||
{
|
||||
$this->href = $url;
|
||||
}
|
||||
|
||||
public function setOption(string $option): void
|
||||
{
|
||||
$this->options[] = $option;
|
||||
$this->color = new Color($this->foreground, $this->background, $this->options);
|
||||
}
|
||||
|
||||
public function unsetOption(string $option): void
|
||||
{
|
||||
$pos = array_search($option, $this->options);
|
||||
if (false !== $pos) {
|
||||
unset($this->options[$pos]);
|
||||
}
|
||||
|
||||
$this->color = new Color($this->foreground, $this->background, $this->options);
|
||||
}
|
||||
|
||||
public function setOptions(array $options): void
|
||||
{
|
||||
$this->color = new Color($this->foreground, $this->background, $this->options = $options);
|
||||
}
|
||||
|
||||
public function apply(string $text): string
|
||||
{
|
||||
$this->handlesHrefGracefully ??= 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR')
|
||||
&& (!getenv('KONSOLE_VERSION') || (int) getenv('KONSOLE_VERSION') > 201100)
|
||||
&& !isset($_SERVER['IDEA_INITIAL_DIRECTORY']);
|
||||
|
||||
if (null !== $this->href && $this->handlesHrefGracefully) {
|
||||
$text = "\033]8;;$this->href\033\\$text\033]8;;\033\\";
|
||||
}
|
||||
|
||||
return $this->color->apply($text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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\Formatter;
|
||||
|
||||
/**
|
||||
* Formatter style interface for defining styles.
|
||||
*
|
||||
* @author Konstantin Kudryashov <ever.zet@gmail.com>
|
||||
*/
|
||||
interface OutputFormatterStyleInterface
|
||||
{
|
||||
/**
|
||||
* Sets style foreground color.
|
||||
*/
|
||||
public function setForeground(?string $color): void;
|
||||
|
||||
/**
|
||||
* Sets style background color.
|
||||
*/
|
||||
public function setBackground(?string $color): void;
|
||||
|
||||
/**
|
||||
* Sets some specific style option.
|
||||
*/
|
||||
public function setOption(string $option): void;
|
||||
|
||||
/**
|
||||
* Unsets some specific style option.
|
||||
*/
|
||||
public function unsetOption(string $option): void;
|
||||
|
||||
/**
|
||||
* Sets multiple style options at once.
|
||||
*/
|
||||
public function setOptions(array $options): void;
|
||||
|
||||
/**
|
||||
* Applies the style to a given text.
|
||||
*/
|
||||
public function apply(string $text): string;
|
||||
}
|
||||
@@ -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\Formatter;
|
||||
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
/**
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*/
|
||||
class OutputFormatterStyleStack implements ResetInterface
|
||||
{
|
||||
/**
|
||||
* @var OutputFormatterStyleInterface[]
|
||||
*/
|
||||
private array $styles = [];
|
||||
|
||||
private OutputFormatterStyleInterface $emptyStyle;
|
||||
|
||||
public function __construct(?OutputFormatterStyleInterface $emptyStyle = null)
|
||||
{
|
||||
$this->emptyStyle = $emptyStyle ?? new OutputFormatterStyle();
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets stack (ie. empty internal arrays).
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->styles = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a style in the stack.
|
||||
*/
|
||||
public function push(OutputFormatterStyleInterface $style): void
|
||||
{
|
||||
$this->styles[] = $style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops a style from the stack.
|
||||
*
|
||||
* @throws InvalidArgumentException When style tags incorrectly nested
|
||||
*/
|
||||
public function pop(?OutputFormatterStyleInterface $style = null): OutputFormatterStyleInterface
|
||||
{
|
||||
if (!$this->styles) {
|
||||
return $this->emptyStyle;
|
||||
}
|
||||
|
||||
if (null === $style) {
|
||||
return array_pop($this->styles);
|
||||
}
|
||||
|
||||
foreach (array_reverse($this->styles, true) as $index => $stackedStyle) {
|
||||
if ($style->apply('') === $stackedStyle->apply('')) {
|
||||
$this->styles = \array_slice($this->styles, 0, $index);
|
||||
|
||||
return $stackedStyle;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Incorrectly nested style tag found.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes current style with stacks top codes.
|
||||
*/
|
||||
public function getCurrent(): OutputFormatterStyleInterface
|
||||
{
|
||||
if (!$this->styles) {
|
||||
return $this->emptyStyle;
|
||||
}
|
||||
|
||||
return $this->styles[\count($this->styles) - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setEmptyStyle(OutputFormatterStyleInterface $emptyStyle): static
|
||||
{
|
||||
$this->emptyStyle = $emptyStyle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyStyle(): OutputFormatterStyleInterface
|
||||
{
|
||||
return $this->emptyStyle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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\Formatter;
|
||||
|
||||
/**
|
||||
* Formatter interface for console output that supports word wrapping.
|
||||
*
|
||||
* @author Roland Franssen <franssen.roland@gmail.com>
|
||||
*/
|
||||
interface WrappableOutputFormatterInterface extends OutputFormatterInterface
|
||||
{
|
||||
/**
|
||||
* Formats a message according to the given styles, wrapping at `$width` (0 means no wrapping).
|
||||
*/
|
||||
public function formatAndWrap(?string $message, int $width): string;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Helps outputting debug information when running an external program from a command.
|
||||
*
|
||||
* An external program can be a Process, an HTTP request, or anything else.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class DebugFormatterHelper extends Helper
|
||||
{
|
||||
private const COLORS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'default'];
|
||||
private array $started = [];
|
||||
private int $count = -1;
|
||||
|
||||
/**
|
||||
* Starts a debug formatting session.
|
||||
*/
|
||||
public function start(string $id, string $message, string $prefix = 'RUN'): string
|
||||
{
|
||||
$this->started[$id] = ['border' => ++$this->count % \count(self::COLORS)];
|
||||
|
||||
return \sprintf("%s<bg=blue;fg=white> %s </> <fg=blue>%s</>\n", $this->getBorder($id), $prefix, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds progress to a formatting session.
|
||||
*/
|
||||
public function progress(string $id, string $buffer, bool $error = false, string $prefix = 'OUT', string $errorPrefix = 'ERR'): string
|
||||
{
|
||||
$message = '';
|
||||
|
||||
if ($error) {
|
||||
if (isset($this->started[$id]['out'])) {
|
||||
$message .= "\n";
|
||||
unset($this->started[$id]['out']);
|
||||
}
|
||||
if (!isset($this->started[$id]['err'])) {
|
||||
$message .= \sprintf('%s<bg=red;fg=white> %s </> ', $this->getBorder($id), $errorPrefix);
|
||||
$this->started[$id]['err'] = true;
|
||||
}
|
||||
|
||||
$message .= str_replace("\n", \sprintf("\n%s<bg=red;fg=white> %s </> ", $this->getBorder($id), $errorPrefix), $buffer);
|
||||
} else {
|
||||
if (isset($this->started[$id]['err'])) {
|
||||
$message .= "\n";
|
||||
unset($this->started[$id]['err']);
|
||||
}
|
||||
if (!isset($this->started[$id]['out'])) {
|
||||
$message .= \sprintf('%s<bg=green;fg=white> %s </> ', $this->getBorder($id), $prefix);
|
||||
$this->started[$id]['out'] = true;
|
||||
}
|
||||
|
||||
$message .= str_replace("\n", \sprintf("\n%s<bg=green;fg=white> %s </> ", $this->getBorder($id), $prefix), $buffer);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a formatting session.
|
||||
*/
|
||||
public function stop(string $id, string $message, bool $successful, string $prefix = 'RES'): string
|
||||
{
|
||||
$trailingEOL = isset($this->started[$id]['out']) || isset($this->started[$id]['err']) ? "\n" : '';
|
||||
|
||||
if ($successful) {
|
||||
return \sprintf("%s%s<bg=green;fg=white> %s </> <fg=green>%s</>\n", $trailingEOL, $this->getBorder($id), $prefix, $message);
|
||||
}
|
||||
|
||||
$message = \sprintf("%s%s<bg=red;fg=white> %s </> <fg=red>%s</>\n", $trailingEOL, $this->getBorder($id), $prefix, $message);
|
||||
|
||||
unset($this->started[$id]['out'], $this->started[$id]['err']);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function getBorder(string $id): string
|
||||
{
|
||||
return \sprintf('<bg=%s> </>', self::COLORS[$this->started[$id]['border']]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'debug_formatter';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?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\Descriptor\DescriptorInterface;
|
||||
use Symfony\Component\Console\Descriptor\JsonDescriptor;
|
||||
use Symfony\Component\Console\Descriptor\MarkdownDescriptor;
|
||||
use Symfony\Component\Console\Descriptor\ReStructuredTextDescriptor;
|
||||
use Symfony\Component\Console\Descriptor\TextDescriptor;
|
||||
use Symfony\Component\Console\Descriptor\XmlDescriptor;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* This class adds helper method to describe objects in various formats.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*/
|
||||
class DescriptorHelper extends Helper
|
||||
{
|
||||
/**
|
||||
* @var DescriptorInterface[]
|
||||
*/
|
||||
private array $descriptors = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->register('txt', new TextDescriptor())
|
||||
->register('xml', new XmlDescriptor())
|
||||
->register('json', new JsonDescriptor())
|
||||
->register('md', new MarkdownDescriptor())
|
||||
->register('rst', new ReStructuredTextDescriptor())
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an object if supported.
|
||||
*
|
||||
* Available options are:
|
||||
* * format: string, the output format name
|
||||
* * raw_text: boolean, sets output type as raw
|
||||
*
|
||||
* @throws InvalidArgumentException when the given format is not supported
|
||||
*/
|
||||
public function describe(OutputInterface $output, ?object $object, array $options = []): void
|
||||
{
|
||||
$options = array_merge([
|
||||
'raw_text' => false,
|
||||
'format' => 'txt',
|
||||
], $options);
|
||||
|
||||
if (!isset($this->descriptors[$options['format']])) {
|
||||
throw new InvalidArgumentException(\sprintf('Unsupported format "%s".', $options['format']));
|
||||
}
|
||||
|
||||
$descriptor = $this->descriptors[$options['format']];
|
||||
$descriptor->describe($output, $object, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a descriptor.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function register(string $format, DescriptorInterface $descriptor): static
|
||||
{
|
||||
$this->descriptors[$format] = $descriptor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'descriptor';
|
||||
}
|
||||
|
||||
public function getFormats(): array
|
||||
{
|
||||
return array_keys($this->descriptors);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?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\Output\OutputInterface;
|
||||
use Symfony\Component\VarDumper\Cloner\ClonerInterface;
|
||||
use Symfony\Component\VarDumper\Cloner\VarCloner;
|
||||
use Symfony\Component\VarDumper\Dumper\CliDumper;
|
||||
|
||||
/**
|
||||
* @author Roland Franssen <franssen.roland@gmail.com>
|
||||
*/
|
||||
final class Dumper
|
||||
{
|
||||
private \Closure $handler;
|
||||
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
private ?CliDumper $dumper = null,
|
||||
private ?ClonerInterface $cloner = null,
|
||||
) {
|
||||
if (class_exists(CliDumper::class)) {
|
||||
$this->handler = function ($var): string {
|
||||
$dumper = $this->dumper ??= new CliDumper(null, null, CliDumper::DUMP_LIGHT_ARRAY | CliDumper::DUMP_COMMA_SEPARATOR);
|
||||
$dumper->setColors($this->output->isDecorated());
|
||||
|
||||
return rtrim($dumper->dump(($this->cloner ??= new VarCloner())->cloneVar($var)->withRefHandles(false), true));
|
||||
};
|
||||
} else {
|
||||
$this->handler = fn ($var): string => match (true) {
|
||||
null === $var => 'null',
|
||||
true === $var => 'true',
|
||||
false === $var => 'false',
|
||||
\is_string($var) => '"'.$var.'"',
|
||||
default => rtrim(print_r($var, true)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(mixed $var): string
|
||||
{
|
||||
return ($this->handler)($var);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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\Formatter\OutputFormatter;
|
||||
|
||||
/**
|
||||
* The Formatter class provides helpers to format messages.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class FormatterHelper extends Helper
|
||||
{
|
||||
/**
|
||||
* Formats a message within a section.
|
||||
*/
|
||||
public function formatSection(string $section, string $message, string $style = 'info'): string
|
||||
{
|
||||
return \sprintf('<%s>[%s]</%s> %s', $style, $section, $style, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a message as a block of text.
|
||||
*/
|
||||
public function formatBlock(string|array $messages, string $style, bool $large = false): string
|
||||
{
|
||||
if (!\is_array($messages)) {
|
||||
$messages = [$messages];
|
||||
}
|
||||
|
||||
$len = 0;
|
||||
$lines = [];
|
||||
foreach ($messages as $message) {
|
||||
$message = OutputFormatter::escape($message);
|
||||
$lines[] = \sprintf($large ? ' %s ' : ' %s ', $message);
|
||||
$len = max(self::width($message) + ($large ? 4 : 2), $len);
|
||||
}
|
||||
|
||||
$messages = $large ? [str_repeat(' ', $len)] : [];
|
||||
for ($i = 0; isset($lines[$i]); ++$i) {
|
||||
$messages[] = $lines[$i].str_repeat(' ', $len - self::width($lines[$i]));
|
||||
}
|
||||
if ($large) {
|
||||
$messages[] = str_repeat(' ', $len);
|
||||
}
|
||||
|
||||
for ($i = 0; isset($messages[$i]); ++$i) {
|
||||
$messages[$i] = \sprintf('<%s>%s</%s>', $style, $messages[$i], $style);
|
||||
}
|
||||
|
||||
return implode("\n", $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a message to the given length.
|
||||
*/
|
||||
public function truncate(string $message, int $length, string $suffix = '...'): string
|
||||
{
|
||||
$computedLength = $length - self::width($suffix);
|
||||
|
||||
if ($computedLength > self::width($message)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return self::substr($message, 0, $length).$suffix;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'formatter';
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?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\Formatter\OutputFormatterInterface;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
/**
|
||||
* Helper is the base class for all helper classes.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
abstract class Helper implements HelperInterface
|
||||
{
|
||||
protected ?HelperSet $helperSet = null;
|
||||
|
||||
public function setHelperSet(?HelperSet $helperSet): void
|
||||
{
|
||||
$this->helperSet = $helperSet;
|
||||
}
|
||||
|
||||
public function getHelperSet(): ?HelperSet
|
||||
{
|
||||
return $this->helperSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the width of a string, using mb_strwidth if it is available.
|
||||
* The width is how many characters positions the string will use.
|
||||
*/
|
||||
public static function width(?string $string): int
|
||||
{
|
||||
$string ??= '';
|
||||
|
||||
if (preg_match('//u', $string)) {
|
||||
$string = preg_replace('/[\p{Cc}\x7F]++/u', '', $string, -1, $count);
|
||||
|
||||
return (new UnicodeString($string))->width(false) + $count;
|
||||
}
|
||||
|
||||
if (false === $encoding = mb_detect_encoding($string, null, true)) {
|
||||
return \strlen($string);
|
||||
}
|
||||
|
||||
return mb_strwidth($string, $encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of a string, using mb_strlen if it is available.
|
||||
* The length is related to how many bytes the string will use.
|
||||
*/
|
||||
public static function length(?string $string): int
|
||||
{
|
||||
$string ??= '';
|
||||
|
||||
if (preg_match('//u', $string)) {
|
||||
return (new UnicodeString($string))->length();
|
||||
}
|
||||
|
||||
if (false === $encoding = mb_detect_encoding($string, null, true)) {
|
||||
return \strlen($string);
|
||||
}
|
||||
|
||||
return mb_strlen($string, $encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of a string, using mb_substr if it is available.
|
||||
*/
|
||||
public static function substr(?string $string, int $from, ?int $length = null): string
|
||||
{
|
||||
$string ??= '';
|
||||
|
||||
if (preg_match('//u', $string)) {
|
||||
return (new UnicodeString($string))->slice($from, $length);
|
||||
}
|
||||
|
||||
if (false === $encoding = mb_detect_encoding($string, null, true)) {
|
||||
return substr($string, $from, $length);
|
||||
}
|
||||
|
||||
return mb_substr($string, $from, $length, $encoding);
|
||||
}
|
||||
|
||||
public static function formatTime(int|float $secs, int $precision = 1): string
|
||||
{
|
||||
$ms = (int) ($secs * 1000);
|
||||
$secs = (int) floor($secs);
|
||||
|
||||
if (0 === $ms) {
|
||||
return '< 1 ms';
|
||||
}
|
||||
|
||||
static $timeFormats = [
|
||||
[1, 'ms'],
|
||||
[1000, 's'],
|
||||
[60000, 'min'],
|
||||
[3600000, 'h'],
|
||||
[86_400_000, 'd'],
|
||||
];
|
||||
|
||||
$times = [];
|
||||
foreach ($timeFormats as $index => $format) {
|
||||
$milliSeconds = isset($timeFormats[$index + 1]) ? $ms % $timeFormats[$index + 1][0] : $ms;
|
||||
|
||||
if (isset($times[$index - $precision])) {
|
||||
unset($times[$index - $precision]);
|
||||
}
|
||||
|
||||
if (0 === $milliSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unitCount = ($milliSeconds / $format[0]);
|
||||
$times[$index] = $unitCount.' '.$format[1];
|
||||
|
||||
if ($ms === $milliSeconds) {
|
||||
break;
|
||||
}
|
||||
|
||||
$ms -= $milliSeconds;
|
||||
}
|
||||
|
||||
return implode(', ', array_reverse($times));
|
||||
}
|
||||
|
||||
public static function formatMemory(int $memory): string
|
||||
{
|
||||
if ($memory >= 1024 * 1024 * 1024) {
|
||||
return \sprintf('%.1f GiB', $memory / 1024 / 1024 / 1024);
|
||||
}
|
||||
|
||||
if ($memory >= 1024 * 1024) {
|
||||
return \sprintf('%.1f MiB', $memory / 1024 / 1024);
|
||||
}
|
||||
|
||||
if ($memory >= 1024) {
|
||||
return \sprintf('%d KiB', $memory / 1024);
|
||||
}
|
||||
|
||||
return \sprintf('%d B', $memory);
|
||||
}
|
||||
|
||||
public static function removeDecoration(OutputFormatterInterface $formatter, ?string $string): string
|
||||
{
|
||||
$isDecorated = $formatter->isDecorated();
|
||||
$formatter->setDecorated(false);
|
||||
// remove <...> formatting
|
||||
$string = $formatter->format($string ?? '');
|
||||
// remove already formatted characters
|
||||
$string = preg_replace("/\033\[[^m]*m/", '', $string ?? '');
|
||||
// remove terminal hyperlinks
|
||||
$string = preg_replace('/\\033]8;[^;]*;[^\\033]*\\033\\\\/', '', $string ?? '');
|
||||
$formatter->setDecorated($isDecorated);
|
||||
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
@@ -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\Helper;
|
||||
|
||||
/**
|
||||
* HelperInterface is the interface all helpers must implement.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
interface HelperInterface
|
||||
{
|
||||
/**
|
||||
* Sets the helper set associated with this helper.
|
||||
*/
|
||||
public function setHelperSet(?HelperSet $helperSet): void;
|
||||
|
||||
/**
|
||||
* Gets the helper set associated with this helper.
|
||||
*/
|
||||
public function getHelperSet(): ?HelperSet;
|
||||
|
||||
/**
|
||||
* Returns the canonical name of this helper.
|
||||
*/
|
||||
public function getName(): string;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?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\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* HelperSet represents a set of helpers to be used with a command.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @implements \IteratorAggregate<string, HelperInterface>
|
||||
*/
|
||||
class HelperSet implements \IteratorAggregate
|
||||
{
|
||||
/** @var array<string, HelperInterface> */
|
||||
private array $helpers = [];
|
||||
|
||||
/**
|
||||
* @param HelperInterface[] $helpers
|
||||
*/
|
||||
public function __construct(array $helpers = [])
|
||||
{
|
||||
foreach ($helpers as $alias => $helper) {
|
||||
$this->set($helper, \is_int($alias) ? null : $alias);
|
||||
}
|
||||
}
|
||||
|
||||
public function set(HelperInterface $helper, ?string $alias = null): void
|
||||
{
|
||||
$this->helpers[$helper->getName()] = $helper;
|
||||
if (null !== $alias) {
|
||||
$this->helpers[$alias] = $helper;
|
||||
}
|
||||
|
||||
$helper->setHelperSet($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the helper if defined.
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return isset($this->helpers[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a helper value.
|
||||
*
|
||||
* @throws InvalidArgumentException if the helper is not defined
|
||||
*/
|
||||
public function get(string $name): HelperInterface
|
||||
{
|
||||
if (!$this->has($name)) {
|
||||
throw new InvalidArgumentException(\sprintf('The helper "%s" is not defined.', $name));
|
||||
}
|
||||
|
||||
return $this->helpers[$name];
|
||||
}
|
||||
|
||||
public function getIterator(): \Traversable
|
||||
{
|
||||
return new \ArrayIterator($this->helpers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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\Input\InputAwareInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
/**
|
||||
* An implementation of InputAwareInterface for Helpers.
|
||||
*
|
||||
* @author Wouter J <waldio.webdesign@gmail.com>
|
||||
*/
|
||||
abstract class InputAwareHelper extends Helper implements InputAwareInterface
|
||||
{
|
||||
protected InputInterface $input;
|
||||
|
||||
public function setInput(InputInterface $input): void
|
||||
{
|
||||
$this->input = $input;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Simple output wrapper for "tagged outputs" instead of wordwrap(). This solution is based on a StackOverflow
|
||||
* answer: https://stackoverflow.com/a/20434776/1476819 from user557597 (alias SLN).
|
||||
*
|
||||
* (?:
|
||||
* # -- Words/Characters
|
||||
* ( # (1 start)
|
||||
* (?> # Atomic Group - Match words with valid breaks
|
||||
* .{1,16} # 1-N characters
|
||||
* # Followed by one of 4 prioritized, non-linebreak whitespace
|
||||
* (?: # break types:
|
||||
* (?<= [^\S\r\n] ) # 1. - Behind a non-linebreak whitespace
|
||||
* [^\S\r\n]? # ( optionally accept an extra non-linebreak whitespace )
|
||||
* | (?= \r? \n ) # 2. - Ahead a linebreak
|
||||
* | $ # 3. - EOS
|
||||
* | [^\S\r\n] # 4. - Accept an extra non-linebreak whitespace
|
||||
* )
|
||||
* ) # End atomic group
|
||||
* |
|
||||
* .{1,16} # No valid word breaks, just break on the N'th character
|
||||
* ) # (1 end)
|
||||
* (?: \r? \n )? # Optional linebreak after Words/Characters
|
||||
* |
|
||||
* # -- Or, Linebreak
|
||||
* (?: \r? \n | $ ) # Stand alone linebreak or at EOS
|
||||
* )
|
||||
*
|
||||
* @author Krisztián Ferenczi <ferenczi.krisztian@gmail.com>
|
||||
*
|
||||
* @see https://stackoverflow.com/a/20434776/1476819
|
||||
*/
|
||||
final class OutputWrapper
|
||||
{
|
||||
private const TAG_OPEN_REGEX_SEGMENT = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
|
||||
private const TAG_CLOSE_REGEX_SEGMENT = '[a-z][^<>]*+';
|
||||
private const URL_PATTERN = 'https?://\S+';
|
||||
|
||||
public function __construct(
|
||||
private bool $allowCutUrls = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function wrap(string $text, int $width, string $break = "\n"): string
|
||||
{
|
||||
if (!$width) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$tagPattern = \sprintf('<(?:(?:%s)|/(?:%s)?)>', self::TAG_OPEN_REGEX_SEGMENT, self::TAG_CLOSE_REGEX_SEGMENT);
|
||||
$limitPattern = "{1,$width}";
|
||||
$patternBlocks = [$tagPattern];
|
||||
if (!$this->allowCutUrls) {
|
||||
$patternBlocks[] = self::URL_PATTERN;
|
||||
}
|
||||
$patternBlocks[] = '.';
|
||||
$blocks = implode('|', $patternBlocks);
|
||||
$rowPattern = "(?:$blocks)$limitPattern";
|
||||
$pattern = \sprintf('#(?:((?>(%1$s)((?<=[^\S\r\n])[^\S\r\n]?|(?=\r?\n)|$|[^\S\r\n]))|(%1$s))(?:\r?\n)?|(?:\r?\n|$))#imux', $rowPattern);
|
||||
$output = rtrim(preg_replace($pattern, '\\1'.$break, $text), $break);
|
||||
|
||||
return str_replace(' '.$break, $break, $output);
|
||||
}
|
||||
}
|
||||
+137
@@ -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\Helper;
|
||||
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* The ProcessHelper class provides helpers to run external processes.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class ProcessHelper extends Helper
|
||||
{
|
||||
/**
|
||||
* Runs an external process.
|
||||
*
|
||||
* @param array|Process $cmd An instance of Process or an array of the command and arguments
|
||||
* @param callable|null $callback A PHP callback to run whenever there is some
|
||||
* output available on STDOUT or STDERR
|
||||
*/
|
||||
public function run(OutputInterface $output, array|Process $cmd, ?string $error = null, ?callable $callback = null, int $verbosity = OutputInterface::VERBOSITY_VERY_VERBOSE): Process
|
||||
{
|
||||
if (!class_exists(Process::class)) {
|
||||
throw new \LogicException('The ProcessHelper cannot be run as the Process component is not installed. Try running "compose require symfony/process".');
|
||||
}
|
||||
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
|
||||
$formatter = $this->getHelperSet()->get('debug_formatter');
|
||||
|
||||
if ($cmd instanceof Process) {
|
||||
$cmd = [$cmd];
|
||||
}
|
||||
|
||||
if (\is_string($cmd[0] ?? null)) {
|
||||
$process = new Process($cmd);
|
||||
$cmd = [];
|
||||
} elseif (($cmd[0] ?? null) instanceof Process) {
|
||||
$process = $cmd[0];
|
||||
unset($cmd[0]);
|
||||
} else {
|
||||
throw new \InvalidArgumentException(\sprintf('Invalid command provided to "%s()": the command should be an array whose first element is either the path to the binary to run or a "Process" object.', __METHOD__));
|
||||
}
|
||||
|
||||
if ($verbosity <= $output->getVerbosity()) {
|
||||
$output->write($formatter->start(spl_object_hash($process), $this->escapeString($process->getCommandLine())));
|
||||
}
|
||||
|
||||
if ($output->isDebug()) {
|
||||
$callback = $this->wrapCallback($output, $process, $callback);
|
||||
}
|
||||
|
||||
$process->run($callback, $cmd);
|
||||
|
||||
if ($verbosity <= $output->getVerbosity()) {
|
||||
$message = $process->isSuccessful() ? 'Command ran successfully' : \sprintf('%s Command did not run successfully', $process->getExitCode());
|
||||
$output->write($formatter->stop(spl_object_hash($process), $message, $process->isSuccessful()));
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful() && null !== $error) {
|
||||
$output->writeln(\sprintf('<error>%s</error>', $this->escapeString($error)));
|
||||
}
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the process.
|
||||
*
|
||||
* This is identical to run() except that an exception is thrown if the process
|
||||
* exits with a non-zero exit code.
|
||||
*
|
||||
* @param array|Process $cmd An instance of Process or a command to run
|
||||
* @param callable|null $callback A PHP callback to run whenever there is some
|
||||
* output available on STDOUT or STDERR
|
||||
*
|
||||
* @throws ProcessFailedException
|
||||
*
|
||||
* @see run()
|
||||
*/
|
||||
public function mustRun(OutputInterface $output, array|Process $cmd, ?string $error = null, ?callable $callback = null, int $verbosity = OutputInterface::VERBOSITY_VERY_VERBOSE): Process
|
||||
{
|
||||
$process = $this->run($output, $cmd, $error, $callback, $verbosity);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new ProcessFailedException($process);
|
||||
}
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Process callback to add debugging output.
|
||||
*/
|
||||
public function wrapCallback(OutputInterface $output, Process $process, ?callable $callback = null): callable
|
||||
{
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
|
||||
$formatter = $this->getHelperSet()->get('debug_formatter');
|
||||
|
||||
return function ($type, $buffer) use ($output, $process, $callback, $formatter) {
|
||||
$output->write($formatter->progress(spl_object_hash($process), $this->escapeString($buffer), Process::ERR === $type));
|
||||
|
||||
if (null !== $callback) {
|
||||
$callback($type, $buffer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function escapeString(string $str): string
|
||||
{
|
||||
return str_replace('<', '\\<', $str);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'process';
|
||||
}
|
||||
}
|
||||
+654
@@ -0,0 +1,654 @@
|
||||
<?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\Cursor;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\ConsoleSectionOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Terminal;
|
||||
|
||||
/**
|
||||
* The ProgressBar provides helpers to display progress output.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Chris Jones <leeked@gmail.com>
|
||||
*/
|
||||
final class ProgressBar
|
||||
{
|
||||
public const FORMAT_VERBOSE = 'verbose';
|
||||
public const FORMAT_VERY_VERBOSE = 'very_verbose';
|
||||
public const FORMAT_DEBUG = 'debug';
|
||||
public const FORMAT_NORMAL = 'normal';
|
||||
|
||||
private const FORMAT_VERBOSE_NOMAX = 'verbose_nomax';
|
||||
private const FORMAT_VERY_VERBOSE_NOMAX = 'very_verbose_nomax';
|
||||
private const FORMAT_DEBUG_NOMAX = 'debug_nomax';
|
||||
private const FORMAT_NORMAL_NOMAX = 'normal_nomax';
|
||||
|
||||
private int $barWidth = 28;
|
||||
private string $barChar;
|
||||
private string $emptyBarChar = '-';
|
||||
private string $progressChar = '>';
|
||||
private ?string $format = null;
|
||||
private ?string $internalFormat = null;
|
||||
private ?int $redrawFreq = 1;
|
||||
private int $writeCount = 0;
|
||||
private float $lastWriteTime = 0;
|
||||
private float $minSecondsBetweenRedraws = 0;
|
||||
private float $maxSecondsBetweenRedraws = 1;
|
||||
private OutputInterface $output;
|
||||
private int $step = 0;
|
||||
private int $startingStep = 0;
|
||||
private ?int $max = null;
|
||||
private int $startTime;
|
||||
private int $stepWidth;
|
||||
private float $percent = 0.0;
|
||||
private array $messages = [];
|
||||
private bool $overwrite = true;
|
||||
private Terminal $terminal;
|
||||
private ?string $previousMessage = null;
|
||||
private Cursor $cursor;
|
||||
private array $placeholders = [];
|
||||
|
||||
private static array $formatters;
|
||||
private static array $formats;
|
||||
|
||||
/**
|
||||
* @param int $max Maximum steps (0 if unknown)
|
||||
*/
|
||||
public function __construct(OutputInterface $output, int $max = 0, float $minSecondsBetweenRedraws = 1 / 25)
|
||||
{
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
|
||||
$this->output = $output;
|
||||
$this->setMaxSteps($max);
|
||||
$this->terminal = new Terminal();
|
||||
|
||||
if (0 < $minSecondsBetweenRedraws) {
|
||||
$this->redrawFreq = null;
|
||||
$this->minSecondsBetweenRedraws = $minSecondsBetweenRedraws;
|
||||
}
|
||||
|
||||
if (!$this->output->isDecorated()) {
|
||||
// disable overwrite when output does not support ANSI codes.
|
||||
$this->overwrite = false;
|
||||
|
||||
// set a reasonable redraw frequency so output isn't flooded
|
||||
$this->redrawFreq = null;
|
||||
}
|
||||
|
||||
$this->startTime = time();
|
||||
$this->cursor = new Cursor($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a placeholder formatter for a given name, globally for all instances of ProgressBar.
|
||||
*
|
||||
* This method also allow you to override an existing placeholder.
|
||||
*
|
||||
* @param string $name The placeholder name (including the delimiter char like %)
|
||||
* @param callable(ProgressBar):string $callable A PHP callable
|
||||
*/
|
||||
public static function setPlaceholderFormatterDefinition(string $name, callable $callable): void
|
||||
{
|
||||
self::$formatters ??= self::initPlaceholderFormatters();
|
||||
|
||||
self::$formatters[$name] = $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the placeholder formatter for a given name.
|
||||
*
|
||||
* @param string $name The placeholder name (including the delimiter char like %)
|
||||
*/
|
||||
public static function getPlaceholderFormatterDefinition(string $name): ?callable
|
||||
{
|
||||
self::$formatters ??= self::initPlaceholderFormatters();
|
||||
|
||||
return self::$formatters[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a placeholder formatter for a given name, for this instance only.
|
||||
*
|
||||
* @param callable(ProgressBar):string $callable A PHP callable
|
||||
*/
|
||||
public function setPlaceholderFormatter(string $name, callable $callable): void
|
||||
{
|
||||
$this->placeholders[$name] = $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the placeholder formatter for a given name.
|
||||
*
|
||||
* @param string $name The placeholder name (including the delimiter char like %)
|
||||
*/
|
||||
public function getPlaceholderFormatter(string $name): ?callable
|
||||
{
|
||||
return $this->placeholders[$name] ?? $this::getPlaceholderFormatterDefinition($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a format for a given name.
|
||||
*
|
||||
* This method also allow you to override an existing format.
|
||||
*
|
||||
* @param string $name The format name
|
||||
* @param string $format A format string
|
||||
*/
|
||||
public static function setFormatDefinition(string $name, string $format): void
|
||||
{
|
||||
self::$formats ??= self::initFormats();
|
||||
|
||||
self::$formats[$name] = $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the format for a given name.
|
||||
*
|
||||
* @param string $name The format name
|
||||
*/
|
||||
public static function getFormatDefinition(string $name): ?string
|
||||
{
|
||||
self::$formats ??= self::initFormats();
|
||||
|
||||
return self::$formats[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a text with a named placeholder.
|
||||
*
|
||||
* The text is displayed when the progress bar is rendered but only
|
||||
* when the corresponding placeholder is part of the custom format line
|
||||
* (by wrapping the name with %).
|
||||
*
|
||||
* @param string $message The text to associate with the placeholder
|
||||
* @param string $name The name of the placeholder
|
||||
*/
|
||||
public function setMessage(string $message, string $name = 'message'): void
|
||||
{
|
||||
$this->messages[$name] = $message;
|
||||
}
|
||||
|
||||
public function getMessage(string $name = 'message'): ?string
|
||||
{
|
||||
return $this->messages[$name] ?? null;
|
||||
}
|
||||
|
||||
public function getStartTime(): int
|
||||
{
|
||||
return $this->startTime;
|
||||
}
|
||||
|
||||
public function getMaxSteps(): int
|
||||
{
|
||||
return $this->max ?? 0;
|
||||
}
|
||||
|
||||
public function getProgress(): int
|
||||
{
|
||||
return $this->step;
|
||||
}
|
||||
|
||||
private function getStepWidth(): int
|
||||
{
|
||||
return $this->stepWidth;
|
||||
}
|
||||
|
||||
public function getProgressPercent(): float
|
||||
{
|
||||
return $this->percent;
|
||||
}
|
||||
|
||||
public function getBarOffset(): float
|
||||
{
|
||||
return floor(null !== $this->max ? $this->percent * $this->barWidth : (null === $this->redrawFreq ? (int) (min(5, $this->barWidth / 15) * $this->writeCount) : $this->step) % $this->barWidth);
|
||||
}
|
||||
|
||||
public function getEstimated(): float
|
||||
{
|
||||
if (0 === $this->step || $this->step === $this->startingStep) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round((time() - $this->startTime) / ($this->step - $this->startingStep) * $this->max);
|
||||
}
|
||||
|
||||
public function getRemaining(): float
|
||||
{
|
||||
if (0 === $this->step || $this->step === $this->startingStep) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round((time() - $this->startTime) / ($this->step - $this->startingStep) * ($this->max - $this->step));
|
||||
}
|
||||
|
||||
public function setBarWidth(int $size): void
|
||||
{
|
||||
$this->barWidth = max(1, $size);
|
||||
}
|
||||
|
||||
public function getBarWidth(): int
|
||||
{
|
||||
return $this->barWidth;
|
||||
}
|
||||
|
||||
public function setBarCharacter(string $char): void
|
||||
{
|
||||
$this->barChar = $char;
|
||||
}
|
||||
|
||||
public function getBarCharacter(): string
|
||||
{
|
||||
return $this->barChar ?? (null !== $this->max ? '=' : $this->emptyBarChar);
|
||||
}
|
||||
|
||||
public function setEmptyBarCharacter(string $char): void
|
||||
{
|
||||
$this->emptyBarChar = $char;
|
||||
}
|
||||
|
||||
public function getEmptyBarCharacter(): string
|
||||
{
|
||||
return $this->emptyBarChar;
|
||||
}
|
||||
|
||||
public function setProgressCharacter(string $char): void
|
||||
{
|
||||
$this->progressChar = $char;
|
||||
}
|
||||
|
||||
public function getProgressCharacter(): string
|
||||
{
|
||||
return $this->progressChar;
|
||||
}
|
||||
|
||||
public function setFormat(string $format): void
|
||||
{
|
||||
$this->format = null;
|
||||
$this->internalFormat = $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the redraw frequency.
|
||||
*
|
||||
* @param int|null $freq The frequency in steps
|
||||
*/
|
||||
public function setRedrawFrequency(?int $freq): void
|
||||
{
|
||||
$this->redrawFreq = null !== $freq ? max(1, $freq) : null;
|
||||
}
|
||||
|
||||
public function minSecondsBetweenRedraws(float $seconds): void
|
||||
{
|
||||
$this->minSecondsBetweenRedraws = $seconds;
|
||||
}
|
||||
|
||||
public function maxSecondsBetweenRedraws(float $seconds): void
|
||||
{
|
||||
$this->maxSecondsBetweenRedraws = $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator that will automatically update the progress bar when iterated.
|
||||
*
|
||||
* @template TKey
|
||||
* @template TValue
|
||||
*
|
||||
* @param iterable<TKey, TValue> $iterable
|
||||
* @param int|null $max Number of steps to complete the bar (0 if indeterminate), if null it will be inferred from $iterable
|
||||
*
|
||||
* @return iterable<TKey, TValue>
|
||||
*/
|
||||
public function iterate(iterable $iterable, ?int $max = null): iterable
|
||||
{
|
||||
if (0 === $max) {
|
||||
$max = null;
|
||||
}
|
||||
|
||||
$max ??= is_countable($iterable) ? \count($iterable) : null;
|
||||
|
||||
if (0 === $max) {
|
||||
$this->max = 0;
|
||||
$this->stepWidth = 2;
|
||||
$this->finish();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->start($max);
|
||||
|
||||
foreach ($iterable as $key => $value) {
|
||||
yield $key => $value;
|
||||
|
||||
$this->advance();
|
||||
}
|
||||
|
||||
$this->finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the progress output.
|
||||
*
|
||||
* @param int|null $max Number of steps to complete the bar (0 if indeterminate), null to leave unchanged
|
||||
* @param int $startAt The starting point of the bar (useful e.g. when resuming a previously started bar)
|
||||
*/
|
||||
public function start(?int $max = null, int $startAt = 0): void
|
||||
{
|
||||
$this->startTime = time();
|
||||
$this->step = $startAt;
|
||||
$this->startingStep = $startAt;
|
||||
|
||||
$startAt > 0 ? $this->setProgress($startAt) : $this->percent = 0.0;
|
||||
|
||||
if (null !== $max) {
|
||||
$this->setMaxSteps($max);
|
||||
}
|
||||
|
||||
$this->display();
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the progress output X steps.
|
||||
*
|
||||
* @param int $step Number of steps to advance
|
||||
*/
|
||||
public function advance(int $step = 1): void
|
||||
{
|
||||
$this->setProgress($this->step + $step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to overwrite the progressbar, false for new line.
|
||||
*/
|
||||
public function setOverwrite(bool $overwrite): void
|
||||
{
|
||||
$this->overwrite = $overwrite;
|
||||
}
|
||||
|
||||
public function setProgress(int $step): void
|
||||
{
|
||||
if ($this->max && $step > $this->max) {
|
||||
$this->max = $step;
|
||||
} elseif ($step < 0) {
|
||||
$step = 0;
|
||||
}
|
||||
|
||||
$redrawFreq = $this->redrawFreq ?? (($this->max ?? 10) / 10);
|
||||
$prevPeriod = $redrawFreq ? (int) ($this->step / $redrawFreq) : 0;
|
||||
$currPeriod = $redrawFreq ? (int) ($step / $redrawFreq) : 0;
|
||||
$this->step = $step;
|
||||
$this->percent = match ($this->max) {
|
||||
null => 0,
|
||||
0 => 1,
|
||||
default => (float) $this->step / $this->max,
|
||||
};
|
||||
$timeInterval = microtime(true) - $this->lastWriteTime;
|
||||
|
||||
// Draw regardless of other limits
|
||||
if ($this->max === $step) {
|
||||
$this->display();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttling
|
||||
if ($timeInterval < $this->minSecondsBetweenRedraws) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw each step period, but not too late
|
||||
if ($prevPeriod !== $currPeriod || $timeInterval >= $this->maxSecondsBetweenRedraws) {
|
||||
$this->display();
|
||||
}
|
||||
}
|
||||
|
||||
public function setMaxSteps(?int $max): void
|
||||
{
|
||||
if (0 === $max) {
|
||||
$max = null;
|
||||
}
|
||||
|
||||
$this->format = null;
|
||||
if (null === $max) {
|
||||
$this->max = null;
|
||||
$this->stepWidth = 4;
|
||||
} else {
|
||||
$this->max = max(0, $max);
|
||||
$this->stepWidth = Helper::width((string) $this->max);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes the progress output.
|
||||
*/
|
||||
public function finish(): void
|
||||
{
|
||||
if (null === $this->max) {
|
||||
$this->max = $this->step;
|
||||
}
|
||||
|
||||
if (($this->step === $this->max || null === $this->max) && !$this->overwrite) {
|
||||
// prevent double 100% output
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setProgress($this->max ?? $this->step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs the current progress string.
|
||||
*/
|
||||
public function display(): void
|
||||
{
|
||||
if (OutputInterface::VERBOSITY_QUIET === $this->output->getVerbosity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->format) {
|
||||
$this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
|
||||
}
|
||||
|
||||
$this->overwrite($this->buildLine());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the progress bar from the current line.
|
||||
*
|
||||
* This is useful if you wish to write some output
|
||||
* while a progress bar is running.
|
||||
* Call display() to show the progress bar again.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
if (!$this->overwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->format) {
|
||||
$this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
|
||||
}
|
||||
|
||||
$this->overwrite('');
|
||||
}
|
||||
|
||||
private function setRealFormat(string $format): void
|
||||
{
|
||||
// try to use the _nomax variant if available
|
||||
if (!$this->max && null !== self::getFormatDefinition($format.'_nomax')) {
|
||||
$this->format = self::getFormatDefinition($format.'_nomax');
|
||||
} elseif (null !== self::getFormatDefinition($format)) {
|
||||
$this->format = self::getFormatDefinition($format);
|
||||
} else {
|
||||
$this->format = $format;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites a previous message to the output.
|
||||
*/
|
||||
private function overwrite(string $message): void
|
||||
{
|
||||
if ($this->previousMessage === $message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$originalMessage = $message;
|
||||
|
||||
if ($this->overwrite) {
|
||||
if (null !== $this->previousMessage) {
|
||||
if ($this->output instanceof ConsoleSectionOutput) {
|
||||
$messageLines = explode("\n", $this->previousMessage);
|
||||
$lineCount = \count($messageLines);
|
||||
|
||||
$lastLineWithoutDecoration = Helper::removeDecoration($this->output->getFormatter(), end($messageLines) ?? '');
|
||||
|
||||
// When the last previous line is empty (without formatting) it is already cleared by the section output, so we don't need to clear it again
|
||||
if ('' === $lastLineWithoutDecoration) {
|
||||
--$lineCount;
|
||||
}
|
||||
|
||||
foreach ($messageLines as $messageLine) {
|
||||
$messageLineLength = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $messageLine));
|
||||
if ($messageLineLength > $this->terminal->getWidth()) {
|
||||
$lineCount += floor($messageLineLength / $this->terminal->getWidth());
|
||||
}
|
||||
}
|
||||
|
||||
$this->output->clear($lineCount);
|
||||
} else {
|
||||
$lineCount = substr_count($this->previousMessage, "\n");
|
||||
for ($i = 0; $i < $lineCount; ++$i) {
|
||||
$this->cursor->moveToColumn(1);
|
||||
$this->cursor->clearLine();
|
||||
$this->cursor->moveUp();
|
||||
}
|
||||
|
||||
$this->cursor->moveToColumn(1);
|
||||
$this->cursor->clearLine();
|
||||
}
|
||||
}
|
||||
} elseif ($this->step > 0) {
|
||||
$message = \PHP_EOL.$message;
|
||||
}
|
||||
|
||||
$this->previousMessage = $originalMessage;
|
||||
$this->lastWriteTime = microtime(true);
|
||||
|
||||
$this->output->write($message);
|
||||
++$this->writeCount;
|
||||
}
|
||||
|
||||
private function determineBestFormat(): string
|
||||
{
|
||||
return match ($this->output->getVerbosity()) {
|
||||
// OutputInterface::VERBOSITY_QUIET: display is disabled anyway
|
||||
OutputInterface::VERBOSITY_VERBOSE => $this->max ? self::FORMAT_VERBOSE : self::FORMAT_VERBOSE_NOMAX,
|
||||
OutputInterface::VERBOSITY_VERY_VERBOSE => $this->max ? self::FORMAT_VERY_VERBOSE : self::FORMAT_VERY_VERBOSE_NOMAX,
|
||||
OutputInterface::VERBOSITY_DEBUG => $this->max ? self::FORMAT_DEBUG : self::FORMAT_DEBUG_NOMAX,
|
||||
default => $this->max ? self::FORMAT_NORMAL : self::FORMAT_NORMAL_NOMAX,
|
||||
};
|
||||
}
|
||||
|
||||
private static function initPlaceholderFormatters(): array
|
||||
{
|
||||
return [
|
||||
'bar' => function (self $bar, OutputInterface $output) {
|
||||
$completeBars = $bar->getBarOffset();
|
||||
$display = str_repeat($bar->getBarCharacter(), $completeBars);
|
||||
if ($completeBars < $bar->getBarWidth()) {
|
||||
$emptyBars = $bar->getBarWidth() - $completeBars - Helper::length(Helper::removeDecoration($output->getFormatter(), $bar->getProgressCharacter()));
|
||||
$display .= $bar->getProgressCharacter().str_repeat($bar->getEmptyBarCharacter(), $emptyBars);
|
||||
}
|
||||
|
||||
return $display;
|
||||
},
|
||||
'elapsed' => fn (self $bar) => Helper::formatTime(time() - $bar->getStartTime(), 2),
|
||||
'remaining' => function (self $bar) {
|
||||
if (null === $bar->getMaxSteps()) {
|
||||
throw new LogicException('Unable to display the remaining time if the maximum number of steps is not set.');
|
||||
}
|
||||
|
||||
return Helper::formatTime($bar->getRemaining(), 2);
|
||||
},
|
||||
'estimated' => function (self $bar) {
|
||||
if (null === $bar->getMaxSteps()) {
|
||||
throw new LogicException('Unable to display the estimated time if the maximum number of steps is not set.');
|
||||
}
|
||||
|
||||
return Helper::formatTime($bar->getEstimated(), 2);
|
||||
},
|
||||
'memory' => fn (self $bar) => Helper::formatMemory(memory_get_usage(true)),
|
||||
'current' => fn (self $bar) => str_pad($bar->getProgress(), $bar->getStepWidth(), ' ', \STR_PAD_LEFT),
|
||||
'max' => fn (self $bar) => $bar->getMaxSteps(),
|
||||
'percent' => fn (self $bar) => floor($bar->getProgressPercent() * 100),
|
||||
];
|
||||
}
|
||||
|
||||
private static function initFormats(): array
|
||||
{
|
||||
return [
|
||||
self::FORMAT_NORMAL => ' %current%/%max% [%bar%] %percent:3s%%',
|
||||
self::FORMAT_NORMAL_NOMAX => ' %current% [%bar%]',
|
||||
|
||||
self::FORMAT_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%',
|
||||
self::FORMAT_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%',
|
||||
|
||||
self::FORMAT_VERY_VERBOSE => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%',
|
||||
self::FORMAT_VERY_VERBOSE_NOMAX => ' %current% [%bar%] %elapsed:6s%',
|
||||
|
||||
self::FORMAT_DEBUG => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%',
|
||||
self::FORMAT_DEBUG_NOMAX => ' %current% [%bar%] %elapsed:6s% %memory:6s%',
|
||||
];
|
||||
}
|
||||
|
||||
private function buildLine(): string
|
||||
{
|
||||
\assert(null !== $this->format);
|
||||
|
||||
$regex = '{%([a-z\-_]+)(?:\:([^%]+))?%}i';
|
||||
$callback = function ($matches) {
|
||||
if ($formatter = $this->getPlaceholderFormatter($matches[1])) {
|
||||
$text = $formatter($this, $this->output);
|
||||
} elseif (isset($this->messages[$matches[1]])) {
|
||||
$text = $this->messages[$matches[1]];
|
||||
} else {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
if (isset($matches[2])) {
|
||||
$text = \sprintf('%'.$matches[2], $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
$line = preg_replace_callback($regex, $callback, $this->format);
|
||||
|
||||
// gets string length for each sub line with multiline format
|
||||
$linesLength = array_map(fn ($subLine) => Helper::width(Helper::removeDecoration($this->output->getFormatter(), rtrim($subLine, "\r"))), explode("\n", $line));
|
||||
|
||||
$linesWidth = max($linesLength);
|
||||
|
||||
$terminalWidth = $this->terminal->getWidth();
|
||||
if ($linesWidth <= $terminalWidth) {
|
||||
return $line;
|
||||
}
|
||||
|
||||
$this->setBarWidth($this->barWidth - $linesWidth + $terminalWidth);
|
||||
|
||||
return preg_replace_callback($regex, $callback, $this->format);
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
<?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\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Exception\LogicException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Kevin Bond <kevinbond@gmail.com>
|
||||
*/
|
||||
class ProgressIndicator
|
||||
{
|
||||
private const FORMATS = [
|
||||
'normal' => ' %indicator% %message%',
|
||||
'normal_no_ansi' => ' %message%',
|
||||
|
||||
'verbose' => ' %indicator% %message% (%elapsed:6s%)',
|
||||
'verbose_no_ansi' => ' %message% (%elapsed:6s%)',
|
||||
|
||||
'very_verbose' => ' %indicator% %message% (%elapsed:6s%, %memory:6s%)',
|
||||
'very_verbose_no_ansi' => ' %message% (%elapsed:6s%, %memory:6s%)',
|
||||
];
|
||||
|
||||
private int $startTime;
|
||||
private ?string $format = null;
|
||||
private ?string $message = null;
|
||||
private array $indicatorValues;
|
||||
private int $indicatorCurrent;
|
||||
private string $finishedIndicatorValue;
|
||||
private float $indicatorUpdateTime;
|
||||
private bool $started = false;
|
||||
private bool $finished = false;
|
||||
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private static array $formatters;
|
||||
|
||||
/**
|
||||
* @param int $indicatorChangeInterval Change interval in milliseconds
|
||||
* @param array|null $indicatorValues Animated indicator characters
|
||||
*/
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
?string $format = null,
|
||||
private int $indicatorChangeInterval = 100,
|
||||
?array $indicatorValues = null,
|
||||
?string $finishedIndicatorValue = null,
|
||||
) {
|
||||
$format ??= $this->determineBestFormat();
|
||||
$indicatorValues ??= ['-', '\\', '|', '/'];
|
||||
$indicatorValues = array_values($indicatorValues);
|
||||
$finishedIndicatorValue ??= '✔';
|
||||
|
||||
if (2 > \count($indicatorValues)) {
|
||||
throw new InvalidArgumentException('Must have at least 2 indicator value characters.');
|
||||
}
|
||||
|
||||
$this->format = self::getFormatDefinition($format);
|
||||
$this->indicatorValues = $indicatorValues;
|
||||
$this->finishedIndicatorValue = $finishedIndicatorValue;
|
||||
$this->startTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current indicator message.
|
||||
*/
|
||||
public function setMessage(?string $message): void
|
||||
{
|
||||
$this->message = $message;
|
||||
|
||||
$this->display();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the indicator output.
|
||||
*/
|
||||
public function start(string $message): void
|
||||
{
|
||||
if ($this->started) {
|
||||
throw new LogicException('Progress indicator already started.');
|
||||
}
|
||||
|
||||
$this->message = $message;
|
||||
$this->started = true;
|
||||
$this->finished = false;
|
||||
$this->startTime = time();
|
||||
$this->indicatorUpdateTime = $this->getCurrentTimeInMilliseconds() + $this->indicatorChangeInterval;
|
||||
$this->indicatorCurrent = 0;
|
||||
|
||||
$this->display();
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the indicator.
|
||||
*/
|
||||
public function advance(): void
|
||||
{
|
||||
if (!$this->started) {
|
||||
throw new LogicException('Progress indicator has not yet been started.');
|
||||
}
|
||||
|
||||
if (!$this->output->isDecorated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTime = $this->getCurrentTimeInMilliseconds();
|
||||
|
||||
if ($currentTime < $this->indicatorUpdateTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->indicatorUpdateTime = $currentTime + $this->indicatorChangeInterval;
|
||||
++$this->indicatorCurrent;
|
||||
|
||||
$this->display();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the indicator with message.
|
||||
*
|
||||
* @param ?string $finishedIndicator
|
||||
*/
|
||||
public function finish(string $message/* , ?string $finishedIndicator = null */): void
|
||||
{
|
||||
$finishedIndicator = 1 < \func_num_args() ? func_get_arg(1) : null;
|
||||
if (null !== $finishedIndicator && !\is_string($finishedIndicator)) {
|
||||
throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be of the type string or null, "%s" given.', __METHOD__, get_debug_type($finishedIndicator)));
|
||||
}
|
||||
|
||||
if (!$this->started) {
|
||||
throw new LogicException('Progress indicator has not yet been started.');
|
||||
}
|
||||
|
||||
if (null !== $finishedIndicator) {
|
||||
$this->finishedIndicatorValue = $finishedIndicator;
|
||||
}
|
||||
|
||||
$this->finished = true;
|
||||
$this->message = $message;
|
||||
$this->display();
|
||||
$this->output->writeln('');
|
||||
$this->started = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the format for a given name.
|
||||
*/
|
||||
public static function getFormatDefinition(string $name): ?string
|
||||
{
|
||||
return self::FORMATS[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a placeholder formatter for a given name.
|
||||
*
|
||||
* This method also allow you to override an existing placeholder.
|
||||
*/
|
||||
public static function setPlaceholderFormatterDefinition(string $name, callable $callable): void
|
||||
{
|
||||
self::$formatters ??= self::initPlaceholderFormatters();
|
||||
|
||||
self::$formatters[$name] = $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the placeholder formatter for a given name (including the delimiter char like %).
|
||||
*/
|
||||
public static function getPlaceholderFormatterDefinition(string $name): ?callable
|
||||
{
|
||||
self::$formatters ??= self::initPlaceholderFormatters();
|
||||
|
||||
return self::$formatters[$name] ?? null;
|
||||
}
|
||||
|
||||
private function display(): void
|
||||
{
|
||||
if (OutputInterface::VERBOSITY_QUIET === $this->output->getVerbosity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->overwrite(preg_replace_callback('{%([a-z\-_]+)(?:\:([^%]+))?%}i', function ($matches) {
|
||||
if ($formatter = self::getPlaceholderFormatterDefinition($matches[1])) {
|
||||
return $formatter($this);
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}, $this->format ?? ''));
|
||||
}
|
||||
|
||||
private function determineBestFormat(): string
|
||||
{
|
||||
return match ($this->output->getVerbosity()) {
|
||||
// OutputInterface::VERBOSITY_QUIET: display is disabled anyway
|
||||
OutputInterface::VERBOSITY_VERBOSE => $this->output->isDecorated() ? 'verbose' : 'verbose_no_ansi',
|
||||
OutputInterface::VERBOSITY_VERY_VERBOSE,
|
||||
OutputInterface::VERBOSITY_DEBUG => $this->output->isDecorated() ? 'very_verbose' : 'very_verbose_no_ansi',
|
||||
default => $this->output->isDecorated() ? 'normal' : 'normal_no_ansi',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites a previous message to the output.
|
||||
*/
|
||||
private function overwrite(string $message): void
|
||||
{
|
||||
if ($this->output->isDecorated()) {
|
||||
$this->output->write("\x0D\x1B[2K");
|
||||
$this->output->write($message);
|
||||
} else {
|
||||
$this->output->writeln($message);
|
||||
}
|
||||
}
|
||||
|
||||
private function getCurrentTimeInMilliseconds(): float
|
||||
{
|
||||
return round(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Closure>
|
||||
*/
|
||||
private static function initPlaceholderFormatters(): array
|
||||
{
|
||||
return [
|
||||
'indicator' => fn (self $indicator) => $indicator->finished ? $indicator->finishedIndicatorValue : $indicator->indicatorValues[$indicator->indicatorCurrent % \count($indicator->indicatorValues)],
|
||||
'message' => fn (self $indicator) => $indicator->message,
|
||||
'elapsed' => fn (self $indicator) => Helper::formatTime(time() - $indicator->startTime, 2),
|
||||
'memory' => fn () => Helper::formatMemory(memory_get_usage(true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
<?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\Cursor;
|
||||
use Symfony\Component\Console\Exception\MissingInputException;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\StreamableInputInterface;
|
||||
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\Question;
|
||||
use Symfony\Component\Console\Terminal;
|
||||
|
||||
use function Symfony\Component\String\s;
|
||||
|
||||
/**
|
||||
* The QuestionHelper class provides helpers to interact with the user.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class QuestionHelper extends Helper
|
||||
{
|
||||
private static bool $stty = true;
|
||||
private static bool $stdinIsInteractive;
|
||||
|
||||
/**
|
||||
* Asks a question to the user.
|
||||
*
|
||||
* @return mixed The user answer
|
||||
*
|
||||
* @throws RuntimeException If there is no data to read in the input stream
|
||||
*/
|
||||
public function ask(InputInterface $input, OutputInterface $output, Question $question): mixed
|
||||
{
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
|
||||
if (!$input->isInteractive()) {
|
||||
return $this->getDefaultAnswer($question);
|
||||
}
|
||||
|
||||
$inputStream = $input instanceof StreamableInputInterface ? $input->getStream() : null;
|
||||
$inputStream ??= \STDIN;
|
||||
|
||||
try {
|
||||
if (!$question->getValidator()) {
|
||||
return $this->doAsk($inputStream, $output, $question);
|
||||
}
|
||||
|
||||
$interviewer = fn () => $this->doAsk($inputStream, $output, $question);
|
||||
|
||||
return $this->validateAttempts($interviewer, $output, $question);
|
||||
} catch (MissingInputException $exception) {
|
||||
$input->setInteractive(false);
|
||||
|
||||
if (null === $fallbackOutput = $this->getDefaultAnswer($question)) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $fallbackOutput;
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'question';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents usage of stty.
|
||||
*/
|
||||
public static function disableStty(): void
|
||||
{
|
||||
self::$stty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the question to the user.
|
||||
*
|
||||
* @param resource $inputStream
|
||||
*
|
||||
* @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
|
||||
*/
|
||||
private function doAsk($inputStream, OutputInterface $output, Question $question): mixed
|
||||
{
|
||||
$this->writePrompt($output, $question);
|
||||
|
||||
$autocomplete = $question->getAutocompleterCallback();
|
||||
|
||||
if (null === $autocomplete || !self::$stty || !Terminal::hasSttyAvailable()) {
|
||||
$ret = false;
|
||||
if ($question->isHidden()) {
|
||||
try {
|
||||
$hiddenResponse = $this->getHiddenResponse($output, $inputStream, $question->isTrimmable());
|
||||
$ret = $question->isTrimmable() ? trim($hiddenResponse) : $hiddenResponse;
|
||||
} catch (RuntimeException $e) {
|
||||
if (!$question->isHiddenFallback()) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $ret) {
|
||||
$isBlocked = stream_get_meta_data($inputStream)['blocked'] ?? true;
|
||||
|
||||
if (!$isBlocked) {
|
||||
stream_set_blocking($inputStream, true);
|
||||
}
|
||||
|
||||
$ret = $this->readInput($inputStream, $question);
|
||||
|
||||
if (!$isBlocked) {
|
||||
stream_set_blocking($inputStream, false);
|
||||
}
|
||||
|
||||
if (false === $ret) {
|
||||
throw new MissingInputException('Aborted.');
|
||||
}
|
||||
if ($question->isTrimmable()) {
|
||||
$ret = trim($ret);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$autocomplete = $this->autocomplete($output, $question, $inputStream, $autocomplete);
|
||||
$ret = $question->isTrimmable() ? trim($autocomplete) : $autocomplete;
|
||||
}
|
||||
|
||||
if ($output instanceof ConsoleSectionOutput) {
|
||||
$output->addContent(''); // add EOL to the question
|
||||
$output->addContent($ret);
|
||||
}
|
||||
|
||||
$ret = \strlen($ret) > 0 ? $ret : $question->getDefault();
|
||||
|
||||
if ($normalizer = $question->getNormalizer()) {
|
||||
return $normalizer($ret);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
private function getDefaultAnswer(Question $question): mixed
|
||||
{
|
||||
$default = $question->getDefault();
|
||||
|
||||
if (null === $default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if ($validator = $question->getValidator()) {
|
||||
return \call_user_func($validator, $default);
|
||||
} elseif ($question instanceof ChoiceQuestion) {
|
||||
$choices = $question->getChoices();
|
||||
|
||||
if (!$question->isMultiselect()) {
|
||||
return $choices[$default] ?? $default;
|
||||
}
|
||||
|
||||
$default = explode(',', $default);
|
||||
foreach ($default as $k => $v) {
|
||||
$v = $question->isTrimmable() ? trim($v) : $v;
|
||||
$default[$k] = $choices[$v] ?? $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs the question prompt.
|
||||
*/
|
||||
protected function writePrompt(OutputInterface $output, Question $question): void
|
||||
{
|
||||
$message = $question->getQuestion();
|
||||
|
||||
if ($question instanceof ChoiceQuestion) {
|
||||
$output->writeln(array_merge([
|
||||
$question->getQuestion(),
|
||||
], $this->formatChoiceQuestionChoices($question, 'info')));
|
||||
|
||||
$message = $question->getPrompt();
|
||||
}
|
||||
|
||||
$output->write($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function formatChoiceQuestionChoices(ChoiceQuestion $question, string $tag): array
|
||||
{
|
||||
$messages = [];
|
||||
|
||||
$maxWidth = max(array_map([__CLASS__, 'width'], array_keys($choices = $question->getChoices())));
|
||||
|
||||
foreach ($choices as $key => $value) {
|
||||
$padding = str_repeat(' ', $maxWidth - self::width($key));
|
||||
|
||||
$messages[] = \sprintf(" [<$tag>%s$padding</$tag>] %s", $key, $value);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs an error message.
|
||||
*/
|
||||
protected function writeError(OutputInterface $output, \Exception $error): void
|
||||
{
|
||||
if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) {
|
||||
$message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error');
|
||||
} else {
|
||||
$message = '<error>'.$error->getMessage().'</error>';
|
||||
}
|
||||
|
||||
$output->writeln($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocompletes a question.
|
||||
*
|
||||
* @param resource $inputStream
|
||||
*/
|
||||
private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string
|
||||
{
|
||||
$cursor = new Cursor($output, $inputStream);
|
||||
|
||||
$fullChoice = '';
|
||||
$ret = '';
|
||||
|
||||
$i = 0;
|
||||
$ofs = -1;
|
||||
$matches = $autocomplete($ret);
|
||||
$numMatches = \count($matches);
|
||||
|
||||
$sttyMode = shell_exec('stty -g');
|
||||
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
|
||||
$r = [$inputStream];
|
||||
$w = [];
|
||||
|
||||
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
|
||||
shell_exec('stty -icanon -echo');
|
||||
|
||||
// Add highlighted text style
|
||||
$output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white'));
|
||||
|
||||
// Read a keypress
|
||||
while (!feof($inputStream)) {
|
||||
while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
|
||||
// Give signal handlers a chance to run
|
||||
$r = [$inputStream];
|
||||
}
|
||||
$c = fread($inputStream, 1);
|
||||
|
||||
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
|
||||
if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) {
|
||||
shell_exec('stty '.$sttyMode);
|
||||
throw new MissingInputException('Aborted.');
|
||||
} elseif ("\177" === $c) { // Backspace Character
|
||||
if (0 === $numMatches && 0 !== $i) {
|
||||
--$i;
|
||||
$cursor->moveLeft(s($fullChoice)->slice(-1)->width(false));
|
||||
|
||||
$fullChoice = self::substr($fullChoice, 0, $i);
|
||||
}
|
||||
|
||||
if (0 === $i) {
|
||||
$ofs = -1;
|
||||
$matches = $autocomplete($ret);
|
||||
$numMatches = \count($matches);
|
||||
} else {
|
||||
$numMatches = 0;
|
||||
}
|
||||
|
||||
// Pop the last character off the end of our string
|
||||
$ret = self::substr($ret, 0, $i);
|
||||
} elseif ("\033" === $c) {
|
||||
// Did we read an escape sequence?
|
||||
$c .= fread($inputStream, 2);
|
||||
|
||||
// A = Up Arrow. B = Down Arrow
|
||||
if (isset($c[2]) && ('A' === $c[2] || 'B' === $c[2])) {
|
||||
if ('A' === $c[2] && -1 === $ofs) {
|
||||
$ofs = 0;
|
||||
}
|
||||
|
||||
if (0 === $numMatches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ofs += ('A' === $c[2]) ? -1 : 1;
|
||||
$ofs = ($numMatches + $ofs) % $numMatches;
|
||||
}
|
||||
} elseif (\ord($c) < 32) {
|
||||
if ("\t" === $c || "\n" === $c) {
|
||||
if ($numMatches > 0 && -1 !== $ofs) {
|
||||
$ret = (string) $matches[$ofs];
|
||||
// Echo out remaining chars for current match
|
||||
$remainingCharacters = substr($ret, \strlen(trim($this->mostRecentlyEnteredValue($fullChoice))));
|
||||
$output->write($remainingCharacters);
|
||||
$fullChoice .= $remainingCharacters;
|
||||
$i = (false === $encoding = mb_detect_encoding($fullChoice, null, true)) ? \strlen($fullChoice) : mb_strlen($fullChoice, $encoding);
|
||||
|
||||
$matches = array_filter(
|
||||
$autocomplete($ret),
|
||||
fn ($match) => '' === $ret || str_starts_with($match, $ret)
|
||||
);
|
||||
$numMatches = \count($matches);
|
||||
$ofs = -1;
|
||||
}
|
||||
|
||||
if ("\n" === $c) {
|
||||
$output->write($c);
|
||||
break;
|
||||
}
|
||||
|
||||
$numMatches = 0;
|
||||
}
|
||||
|
||||
continue;
|
||||
} else {
|
||||
if ("\x80" <= $c) {
|
||||
$c .= fread($inputStream, ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3][$c & "\xF0"]);
|
||||
}
|
||||
|
||||
$output->write($c);
|
||||
$ret .= $c;
|
||||
$fullChoice .= $c;
|
||||
++$i;
|
||||
|
||||
$tempRet = $ret;
|
||||
|
||||
if ($question instanceof ChoiceQuestion && $question->isMultiselect()) {
|
||||
$tempRet = $this->mostRecentlyEnteredValue($fullChoice);
|
||||
}
|
||||
|
||||
$numMatches = 0;
|
||||
$ofs = 0;
|
||||
|
||||
foreach ($autocomplete($ret) as $value) {
|
||||
// If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
|
||||
if (str_starts_with($value, $tempRet)) {
|
||||
$matches[$numMatches++] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cursor->clearLineAfter();
|
||||
|
||||
if ($numMatches > 0 && -1 !== $ofs) {
|
||||
$cursor->savePosition();
|
||||
// Write highlighted text, complete the partially entered response
|
||||
$charactersEntered = \strlen(trim($this->mostRecentlyEnteredValue($fullChoice)));
|
||||
$output->write('<hl>'.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).'</hl>');
|
||||
$cursor->restorePosition();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset stty so it behaves normally again
|
||||
shell_exec('stty '.$sttyMode);
|
||||
|
||||
return $fullChoice;
|
||||
}
|
||||
|
||||
private function mostRecentlyEnteredValue(string $entered): string
|
||||
{
|
||||
// Determine the most recent value that the user entered
|
||||
if (!str_contains($entered, ',')) {
|
||||
return $entered;
|
||||
}
|
||||
|
||||
$choices = explode(',', $entered);
|
||||
if ('' !== $lastChoice = trim($choices[\count($choices) - 1])) {
|
||||
return $lastChoice;
|
||||
}
|
||||
|
||||
return $entered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a hidden response from user.
|
||||
*
|
||||
* @param resource $inputStream The handler resource
|
||||
* @param bool $trimmable Is the answer trimmable
|
||||
*
|
||||
* @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
|
||||
*/
|
||||
private function getHiddenResponse(OutputInterface $output, $inputStream, bool $trimmable = true): string
|
||||
{
|
||||
if ('\\' === \DIRECTORY_SEPARATOR) {
|
||||
$exe = __DIR__.'/../Resources/bin/hiddeninput.exe';
|
||||
|
||||
// handle code running from a phar
|
||||
if (str_starts_with(__FILE__, 'phar:')) {
|
||||
$tmpExe = sys_get_temp_dir().'/hiddeninput.exe';
|
||||
copy($exe, $tmpExe);
|
||||
$exe = $tmpExe;
|
||||
}
|
||||
|
||||
$sExec = shell_exec('"'.$exe.'"');
|
||||
$value = $trimmable ? rtrim($sExec) : $sExec;
|
||||
$output->writeln('');
|
||||
|
||||
if (isset($tmpExe)) {
|
||||
unlink($tmpExe);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (self::$stty && Terminal::hasSttyAvailable()) {
|
||||
$sttyMode = shell_exec('stty -g');
|
||||
shell_exec('stty -echo');
|
||||
} elseif ($this->isInteractiveInput($inputStream)) {
|
||||
throw new RuntimeException('Unable to hide the response.');
|
||||
}
|
||||
|
||||
$value = fgets($inputStream, 4096);
|
||||
|
||||
if (4095 === \strlen($value)) {
|
||||
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
|
||||
$errOutput->warning('The value was possibly truncated by your shell or terminal emulator');
|
||||
}
|
||||
|
||||
if (self::$stty && Terminal::hasSttyAvailable()) {
|
||||
shell_exec('stty '.$sttyMode);
|
||||
}
|
||||
|
||||
if (false === $value) {
|
||||
throw new MissingInputException('Aborted.');
|
||||
}
|
||||
if ($trimmable) {
|
||||
$value = trim($value);
|
||||
}
|
||||
$output->writeln('');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an attempt.
|
||||
*
|
||||
* @param callable $interviewer A callable that will ask for a question and return the result
|
||||
*
|
||||
* @throws \Exception In case the max number of attempts has been reached and no valid response has been given
|
||||
*/
|
||||
private function validateAttempts(callable $interviewer, OutputInterface $output, Question $question): mixed
|
||||
{
|
||||
$error = null;
|
||||
$attempts = $question->getMaxAttempts();
|
||||
|
||||
while (null === $attempts || $attempts--) {
|
||||
if (null !== $error) {
|
||||
$this->writeError($output, $error);
|
||||
}
|
||||
|
||||
try {
|
||||
return $question->getValidator()($interviewer());
|
||||
} catch (RuntimeException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $error) {
|
||||
}
|
||||
}
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
private function isInteractiveInput($inputStream): bool
|
||||
{
|
||||
if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset(self::$stdinIsInteractive)) {
|
||||
return self::$stdinIsInteractive;
|
||||
}
|
||||
|
||||
return self::$stdinIsInteractive = @stream_isatty(fopen('php://stdin', 'r'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one or more lines of input and returns what is read.
|
||||
*
|
||||
* @param resource $inputStream The handler resource
|
||||
* @param Question $question The question being asked
|
||||
*/
|
||||
private function readInput($inputStream, Question $question): string|false
|
||||
{
|
||||
if (!$question->isMultiline()) {
|
||||
$cp = $this->setIOCodepage();
|
||||
$ret = fgets($inputStream, 4096);
|
||||
|
||||
return $this->resetIOCodepage($cp, $ret);
|
||||
}
|
||||
|
||||
$multiLineStreamReader = $this->cloneInputStream($inputStream);
|
||||
if (null === $multiLineStreamReader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ret = '';
|
||||
$cp = $this->setIOCodepage();
|
||||
while (false !== ($char = fgetc($multiLineStreamReader))) {
|
||||
if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") {
|
||||
break;
|
||||
}
|
||||
$ret .= $char;
|
||||
}
|
||||
|
||||
if (stream_get_meta_data($inputStream)['seekable']) {
|
||||
fseek($inputStream, ftell($multiLineStreamReader));
|
||||
}
|
||||
|
||||
return $this->resetIOCodepage($cp, $ret);
|
||||
}
|
||||
|
||||
private function setIOCodepage(): int
|
||||
{
|
||||
if (\function_exists('sapi_windows_cp_set')) {
|
||||
$cp = sapi_windows_cp_get();
|
||||
sapi_windows_cp_set(sapi_windows_cp_get('oem'));
|
||||
|
||||
return $cp;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets console I/O to the specified code page and converts the user input.
|
||||
*/
|
||||
private function resetIOCodepage(int $cp, string|false $input): string|false
|
||||
{
|
||||
if (0 !== $cp) {
|
||||
sapi_windows_cp_set($cp);
|
||||
|
||||
if (false !== $input && '' !== $input) {
|
||||
$input = sapi_windows_cp_conv(sapi_windows_cp_get('oem'), $cp, $input);
|
||||
}
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an input stream in order to act on one instance of the same
|
||||
* stream without affecting the other instance.
|
||||
*
|
||||
* @param resource $inputStream The handler resource
|
||||
*
|
||||
* @return resource|null The cloned resource, null in case it could not be cloned
|
||||
*/
|
||||
private function cloneInputStream($inputStream)
|
||||
{
|
||||
$streamMetaData = stream_get_meta_data($inputStream);
|
||||
$seekable = $streamMetaData['seekable'] ?? false;
|
||||
$mode = $streamMetaData['mode'] ?? 'rb';
|
||||
$uri = $streamMetaData['uri'] ?? null;
|
||||
|
||||
if (null === $uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cloneStream = fopen($uri, $mode);
|
||||
|
||||
// For seekable and writable streams, add all the same data to the
|
||||
// cloned stream and then seek to the same offset.
|
||||
if (true === $seekable && !\in_array($mode, ['r', 'rb', 'rt'])) {
|
||||
$offset = ftell($inputStream);
|
||||
rewind($inputStream);
|
||||
stream_copy_to_stream($inputStream, $cloneStream);
|
||||
fseek($inputStream, $offset);
|
||||
fseek($cloneStream, $offset);
|
||||
}
|
||||
|
||||
return $cloneStream;
|
||||
}
|
||||
}
|
||||
@@ -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\Helper;
|
||||
|
||||
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ChoiceQuestion;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Symfony Style Guide compliant question helper.
|
||||
*
|
||||
* @author Kevin Bond <kevinbond@gmail.com>
|
||||
*/
|
||||
class SymfonyQuestionHelper extends QuestionHelper
|
||||
{
|
||||
protected function writePrompt(OutputInterface $output, Question $question): void
|
||||
{
|
||||
$text = OutputFormatter::escapeTrailingBackslash($question->getQuestion());
|
||||
$default = $question->getDefault();
|
||||
|
||||
if ($question->isMultiline()) {
|
||||
$text .= \sprintf(' (press %s to continue)', $this->getEofShortcut());
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case null === $default:
|
||||
$text = \sprintf(' <info>%s</info>:', $text);
|
||||
|
||||
break;
|
||||
|
||||
case $question instanceof ConfirmationQuestion:
|
||||
$text = \sprintf(' <info>%s (yes/no)</info> [<comment>%s</comment>]:', $text, $default ? 'yes' : 'no');
|
||||
|
||||
break;
|
||||
|
||||
case $question instanceof ChoiceQuestion && $question->isMultiselect():
|
||||
$choices = $question->getChoices();
|
||||
$default = explode(',', $default);
|
||||
|
||||
foreach ($default as $key => $value) {
|
||||
$default[$key] = $choices[trim($value)];
|
||||
}
|
||||
|
||||
$text = \sprintf(' <info>%s</info> [<comment>%s</comment>]:', $text, OutputFormatter::escape(implode(', ', $default)));
|
||||
|
||||
break;
|
||||
|
||||
case $question instanceof ChoiceQuestion:
|
||||
$choices = $question->getChoices();
|
||||
$text = \sprintf(' <info>%s</info> [<comment>%s</comment>]:', $text, OutputFormatter::escape($choices[$default] ?? $default));
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
$text = \sprintf(' <info>%s</info> [<comment>%s</comment>]:', $text, OutputFormatter::escape($default));
|
||||
}
|
||||
|
||||
$output->writeln($text);
|
||||
|
||||
$prompt = ' > ';
|
||||
|
||||
if ($question instanceof ChoiceQuestion) {
|
||||
$output->writeln($this->formatChoiceQuestionChoices($question, 'comment'));
|
||||
|
||||
$prompt = $question->getPrompt();
|
||||
}
|
||||
|
||||
$output->write($prompt);
|
||||
}
|
||||
|
||||
protected function writeError(OutputInterface $output, \Exception $error): void
|
||||
{
|
||||
if ($output instanceof SymfonyStyle) {
|
||||
$output->newLine();
|
||||
$output->error($error->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
parent::writeError($output, $error);
|
||||
}
|
||||
|
||||
private function getEofShortcut(): string
|
||||
{
|
||||
if ('Windows' === \PHP_OS_FAMILY) {
|
||||
return '<comment>Ctrl+Z</comment> then <comment>Enter</comment>';
|
||||
}
|
||||
|
||||
return '<comment>Ctrl+D</comment>';
|
||||
}
|
||||
}
|
||||
+971
@@ -0,0 +1,971 @@
|
||||
<?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\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||
use Symfony\Component\Console\Formatter\WrappableOutputFormatterInterface;
|
||||
use Symfony\Component\Console\Output\ConsoleSectionOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Provides helpers to display a table.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Саша Стаменковић <umpirsky@gmail.com>
|
||||
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
|
||||
* @author Max Grigorian <maxakawizard@gmail.com>
|
||||
* @author Dany Maillard <danymaillard93b@gmail.com>
|
||||
*/
|
||||
class Table
|
||||
{
|
||||
private const SEPARATOR_TOP = 0;
|
||||
private const SEPARATOR_TOP_BOTTOM = 1;
|
||||
private const SEPARATOR_MID = 2;
|
||||
private const SEPARATOR_BOTTOM = 3;
|
||||
private const BORDER_OUTSIDE = 0;
|
||||
private const BORDER_INSIDE = 1;
|
||||
private const DISPLAY_ORIENTATION_DEFAULT = 'default';
|
||||
private const DISPLAY_ORIENTATION_HORIZONTAL = 'horizontal';
|
||||
private const DISPLAY_ORIENTATION_VERTICAL = 'vertical';
|
||||
|
||||
private ?string $headerTitle = null;
|
||||
private ?string $footerTitle = null;
|
||||
private array $headers = [];
|
||||
private array $rows = [];
|
||||
private array $effectiveColumnWidths = [];
|
||||
private int $numberOfColumns;
|
||||
private TableStyle $style;
|
||||
private array $columnStyles = [];
|
||||
private array $columnWidths = [];
|
||||
private array $columnMaxWidths = [];
|
||||
private bool $rendered = false;
|
||||
private string $displayOrientation = self::DISPLAY_ORIENTATION_DEFAULT;
|
||||
|
||||
private static array $styles;
|
||||
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
self::$styles ??= self::initStyles();
|
||||
|
||||
$this->setStyle('default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a style definition.
|
||||
*/
|
||||
public static function setStyleDefinition(string $name, TableStyle $style): void
|
||||
{
|
||||
self::$styles ??= self::initStyles();
|
||||
|
||||
self::$styles[$name] = $style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a style definition by name.
|
||||
*/
|
||||
public static function getStyleDefinition(string $name): TableStyle
|
||||
{
|
||||
self::$styles ??= self::initStyles();
|
||||
|
||||
return self::$styles[$name] ?? throw new InvalidArgumentException(\sprintf('Style "%s" is not defined.', $name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets table style.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setStyle(TableStyle|string $name): static
|
||||
{
|
||||
$this->style = $this->resolveStyle($name);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current table style.
|
||||
*/
|
||||
public function getStyle(): TableStyle
|
||||
{
|
||||
return $this->style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets table column style.
|
||||
*
|
||||
* @param TableStyle|string $name The style name or a TableStyle instance
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setColumnStyle(int $columnIndex, TableStyle|string $name): static
|
||||
{
|
||||
$this->columnStyles[$columnIndex] = $this->resolveStyle($name);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current style for a column.
|
||||
*
|
||||
* If style was not set, it returns the global table style.
|
||||
*/
|
||||
public function getColumnStyle(int $columnIndex): TableStyle
|
||||
{
|
||||
return $this->columnStyles[$columnIndex] ?? $this->getStyle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum width of a column.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setColumnWidth(int $columnIndex, int $width): static
|
||||
{
|
||||
$this->columnWidths[$columnIndex] = $width;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum width of all columns.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setColumnWidths(array $widths): static
|
||||
{
|
||||
$this->columnWidths = [];
|
||||
foreach ($widths as $index => $width) {
|
||||
$this->setColumnWidth($index, $width);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum width of a column.
|
||||
*
|
||||
* Any cell within this column which contents exceeds the specified width will be wrapped into multiple lines, while
|
||||
* formatted strings are preserved.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setColumnMaxWidth(int $columnIndex, int $width): static
|
||||
{
|
||||
if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) {
|
||||
throw new \LogicException(\sprintf('Setting a maximum column width is only supported when using a "%s" formatter, got "%s".', WrappableOutputFormatterInterface::class, get_debug_type($this->output->getFormatter())));
|
||||
}
|
||||
|
||||
$this->columnMaxWidths[$columnIndex] = $width;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setHeaders(array $headers): static
|
||||
{
|
||||
$headers = array_values($headers);
|
||||
if ($headers && !\is_array($headers[0])) {
|
||||
$headers = [$headers];
|
||||
}
|
||||
|
||||
$this->headers = $headers;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setRows(array $rows): static
|
||||
{
|
||||
$this->rows = [];
|
||||
|
||||
return $this->addRows($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addRows(array $rows): static
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$this->addRow($row);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addRow(TableSeparator|array $row): static
|
||||
{
|
||||
if ($row instanceof TableSeparator) {
|
||||
$this->rows[] = $row;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->rows[] = array_values($row);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a row to the table, and re-renders the table.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function appendRow(TableSeparator|array $row): static
|
||||
{
|
||||
if (!$this->output instanceof ConsoleSectionOutput) {
|
||||
throw new RuntimeException(\sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__));
|
||||
}
|
||||
|
||||
if ($this->rendered) {
|
||||
$this->output->clear($this->calculateRowCount());
|
||||
}
|
||||
|
||||
$this->addRow($row);
|
||||
$this->render();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setRow(int|string $column, array $row): static
|
||||
{
|
||||
$this->rows[$column] = $row;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setHeaderTitle(?string $title): static
|
||||
{
|
||||
$this->headerTitle = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setFooterTitle(?string $title): static
|
||||
{
|
||||
$this->footerTitle = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setHorizontal(bool $horizontal = true): static
|
||||
{
|
||||
$this->displayOrientation = $horizontal ? self::DISPLAY_ORIENTATION_HORIZONTAL : self::DISPLAY_ORIENTATION_DEFAULT;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setVertical(bool $vertical = true): static
|
||||
{
|
||||
$this->displayOrientation = $vertical ? self::DISPLAY_ORIENTATION_VERTICAL : self::DISPLAY_ORIENTATION_DEFAULT;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders table to output.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* +---------------+-----------------------+------------------+
|
||||
* | ISBN | Title | Author |
|
||||
* +---------------+-----------------------+------------------+
|
||||
* | 99921-58-10-7 | Divine Comedy | Dante Alighieri |
|
||||
* | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens |
|
||||
* | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien |
|
||||
* +---------------+-----------------------+------------------+
|
||||
*/
|
||||
public function render(): void
|
||||
{
|
||||
$divider = new TableSeparator();
|
||||
$isCellWithColspan = static fn ($cell) => $cell instanceof TableCell && $cell->getColspan() >= 2;
|
||||
|
||||
$horizontal = self::DISPLAY_ORIENTATION_HORIZONTAL === $this->displayOrientation;
|
||||
$vertical = self::DISPLAY_ORIENTATION_VERTICAL === $this->displayOrientation;
|
||||
|
||||
$rows = [];
|
||||
if ($horizontal) {
|
||||
foreach ($this->headers[0] ?? [] as $i => $header) {
|
||||
$rows[$i] = [$header];
|
||||
foreach ($this->rows as $row) {
|
||||
if ($row instanceof TableSeparator) {
|
||||
continue;
|
||||
}
|
||||
if (isset($row[$i])) {
|
||||
$rows[$i][] = $row[$i];
|
||||
} elseif ($isCellWithColspan($rows[$i][0])) {
|
||||
// Noop, there is a "title"
|
||||
} else {
|
||||
$rows[$i][] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($vertical) {
|
||||
$formatter = $this->output->getFormatter();
|
||||
$maxHeaderLength = array_reduce($this->headers[0] ?? [], static fn ($max, $header) => max($max, Helper::width(Helper::removeDecoration($formatter, $header))), 0);
|
||||
|
||||
foreach ($this->rows as $row) {
|
||||
if ($row instanceof TableSeparator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rows) {
|
||||
$rows[] = [$divider];
|
||||
}
|
||||
|
||||
$containsColspan = false;
|
||||
foreach ($row as $cell) {
|
||||
if ($containsColspan = $isCellWithColspan($cell)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$headers = $this->headers[0] ?? [];
|
||||
$maxRows = max(\count($headers), \count($row));
|
||||
for ($i = 0; $i < $maxRows; ++$i) {
|
||||
$cell = (string) ($row[$i] ?? '');
|
||||
|
||||
$eol = str_contains($cell, "\r\n") ? "\r\n" : "\n";
|
||||
$parts = explode($eol, $cell);
|
||||
foreach ($parts as $idx => $part) {
|
||||
if ($headers && !$containsColspan) {
|
||||
if (0 === $idx) {
|
||||
$rows[] = [\sprintf(
|
||||
'<comment>%s%s</>: %s',
|
||||
str_repeat(' ', $maxHeaderLength - Helper::width(Helper::removeDecoration($formatter, $headers[$i] ?? ''))),
|
||||
$headers[$i] ?? '',
|
||||
$part
|
||||
)];
|
||||
} else {
|
||||
$rows[] = [\sprintf(
|
||||
'%s %s',
|
||||
str_pad('', $maxHeaderLength, ' ', \STR_PAD_LEFT),
|
||||
$part
|
||||
)];
|
||||
}
|
||||
} elseif ('' !== $cell) {
|
||||
$rows[] = [$part];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rows = array_merge($this->headers, [$divider], $this->rows);
|
||||
}
|
||||
|
||||
$this->calculateNumberOfColumns($rows);
|
||||
|
||||
$rowGroups = $this->buildTableRows($rows);
|
||||
$this->calculateColumnsWidth($rowGroups);
|
||||
|
||||
$isHeader = !$horizontal;
|
||||
$isFirstRow = $horizontal;
|
||||
$hasTitle = (bool) $this->headerTitle;
|
||||
|
||||
foreach ($rowGroups as $rowGroup) {
|
||||
$isHeaderSeparatorRendered = false;
|
||||
|
||||
foreach ($rowGroup as $row) {
|
||||
if ($divider === $row) {
|
||||
$isHeader = false;
|
||||
$isFirstRow = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($row instanceof TableSeparator) {
|
||||
$this->renderRowSeparator();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isHeader && !$isHeaderSeparatorRendered && $this->style->displayOutsideBorder()) {
|
||||
$this->renderRowSeparator(
|
||||
self::SEPARATOR_TOP,
|
||||
$hasTitle ? $this->headerTitle : null,
|
||||
$hasTitle ? $this->style->getHeaderTitleFormat() : null
|
||||
);
|
||||
$hasTitle = false;
|
||||
$isHeaderSeparatorRendered = true;
|
||||
}
|
||||
|
||||
if ($isFirstRow) {
|
||||
$this->renderRowSeparator(
|
||||
$horizontal ? self::SEPARATOR_TOP : self::SEPARATOR_TOP_BOTTOM,
|
||||
$hasTitle ? $this->headerTitle : null,
|
||||
$hasTitle ? $this->style->getHeaderTitleFormat() : null
|
||||
);
|
||||
$isFirstRow = false;
|
||||
$hasTitle = false;
|
||||
}
|
||||
|
||||
if ($vertical) {
|
||||
$isHeader = false;
|
||||
$isFirstRow = false;
|
||||
}
|
||||
|
||||
if ($horizontal) {
|
||||
$this->renderRow($row, $this->style->getCellRowFormat(), $this->style->getCellHeaderFormat());
|
||||
} else {
|
||||
$this->renderRow($row, $isHeader ? $this->style->getCellHeaderFormat() : $this->style->getCellRowFormat());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->getStyle()->displayOutsideBorder()) {
|
||||
$this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat());
|
||||
}
|
||||
|
||||
$this->cleanup();
|
||||
$this->rendered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders horizontal header separator.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* +-----+-----------+-------+
|
||||
*/
|
||||
private function renderRowSeparator(int $type = self::SEPARATOR_MID, ?string $title = null, ?string $titleFormat = null): void
|
||||
{
|
||||
if (!$count = $this->numberOfColumns) {
|
||||
return;
|
||||
}
|
||||
|
||||
$borders = $this->style->getBorderChars();
|
||||
if (!$borders[0] && !$borders[2] && !$this->style->getCrossingChar()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$crossings = $this->style->getCrossingChars();
|
||||
if (self::SEPARATOR_MID === $type) {
|
||||
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[2], $crossings[8], $crossings[0], $crossings[4]];
|
||||
} elseif (self::SEPARATOR_TOP === $type) {
|
||||
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[1], $crossings[2], $crossings[3]];
|
||||
} elseif (self::SEPARATOR_TOP_BOTTOM === $type) {
|
||||
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[9], $crossings[10], $crossings[11]];
|
||||
} else {
|
||||
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[7], $crossings[6], $crossings[5]];
|
||||
}
|
||||
|
||||
$markup = $leftChar;
|
||||
for ($column = 0; $column < $count; ++$column) {
|
||||
$markup .= str_repeat($horizontal, $this->effectiveColumnWidths[$column]);
|
||||
$markup .= $column === $count - 1 ? $rightChar : $midChar;
|
||||
}
|
||||
|
||||
if (null !== $title) {
|
||||
$titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = \sprintf($titleFormat, $title)));
|
||||
$markupLength = Helper::width($markup);
|
||||
if ($titleLength > $limit = $markupLength - 4) {
|
||||
$titleLength = $limit;
|
||||
$formatLength = Helper::width(Helper::removeDecoration($formatter, \sprintf($titleFormat, '')));
|
||||
$formattedTitle = \sprintf($titleFormat, Helper::substr($title, 0, $limit - $formatLength - 3).'...');
|
||||
}
|
||||
|
||||
$titleStart = intdiv($markupLength - $titleLength, 2);
|
||||
if (false === mb_detect_encoding($markup, null, true)) {
|
||||
$markup = substr_replace($markup, $formattedTitle, $titleStart, $titleLength);
|
||||
} else {
|
||||
$markup = mb_substr($markup, 0, $titleStart).$formattedTitle.mb_substr($markup, $titleStart + $titleLength);
|
||||
}
|
||||
}
|
||||
|
||||
$this->output->writeln(\sprintf($this->style->getBorderFormat(), $markup));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders vertical column separator.
|
||||
*/
|
||||
private function renderColumnSeparator(int $type = self::BORDER_OUTSIDE): string
|
||||
{
|
||||
$borders = $this->style->getBorderChars();
|
||||
|
||||
return \sprintf($this->style->getBorderFormat(), self::BORDER_OUTSIDE === $type ? $borders[1] : $borders[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders table row.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens |
|
||||
*/
|
||||
private function renderRow(array $row, string $cellFormat, ?string $firstCellFormat = null): void
|
||||
{
|
||||
$rowContent = $this->renderColumnSeparator(self::BORDER_OUTSIDE);
|
||||
$columns = $this->getRowColumns($row);
|
||||
$last = \count($columns) - 1;
|
||||
foreach ($columns as $i => $column) {
|
||||
if ($firstCellFormat && 0 === $i) {
|
||||
$rowContent .= $this->renderCell($row, $column, $firstCellFormat);
|
||||
} else {
|
||||
$rowContent .= $this->renderCell($row, $column, $cellFormat);
|
||||
}
|
||||
$rowContent .= $this->renderColumnSeparator($last === $i ? self::BORDER_OUTSIDE : self::BORDER_INSIDE);
|
||||
}
|
||||
$this->output->writeln($rowContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders table cell with padding.
|
||||
*/
|
||||
private function renderCell(array $row, int $column, string $cellFormat): string
|
||||
{
|
||||
$cell = $row[$column] ?? '';
|
||||
$width = $this->effectiveColumnWidths[$column];
|
||||
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
|
||||
// add the width of the following columns(numbers of colspan).
|
||||
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $nextColumn) {
|
||||
$width += $this->getColumnSeparatorWidth() + $this->effectiveColumnWidths[$nextColumn];
|
||||
}
|
||||
}
|
||||
|
||||
// str_pad won't work properly with multi-byte strings, we need to fix the padding
|
||||
$width += \strlen($cell) - Helper::width($cell) - substr_count($cell, "\0");
|
||||
$style = $this->getColumnStyle($column);
|
||||
|
||||
if ($cell instanceof TableSeparator) {
|
||||
return \sprintf($style->getBorderFormat(), str_repeat($style->getBorderChars()[2], $width));
|
||||
}
|
||||
|
||||
$width += Helper::length($cell) - Helper::length(Helper::removeDecoration($this->output->getFormatter(), $cell));
|
||||
$content = \sprintf($style->getCellRowContentFormat(), $cell);
|
||||
|
||||
$padType = $style->getPadType();
|
||||
if ($cell instanceof TableCell && $cell->getStyle() instanceof TableCellStyle) {
|
||||
$isNotStyledByTag = !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell);
|
||||
if ($isNotStyledByTag) {
|
||||
$cellFormat = $cell->getStyle()->getCellFormat();
|
||||
if (!\is_string($cellFormat)) {
|
||||
$tag = http_build_query($cell->getStyle()->getTagOptions(), '', ';');
|
||||
$cellFormat = '<'.$tag.'>%s</>';
|
||||
}
|
||||
|
||||
if (str_contains($content, '</>')) {
|
||||
$content = str_replace('</>', '', $content);
|
||||
$width -= 3;
|
||||
}
|
||||
if (str_contains($content, '<fg=default;bg=default>')) {
|
||||
$content = str_replace('<fg=default;bg=default>', '', $content);
|
||||
$width -= \strlen('<fg=default;bg=default>');
|
||||
}
|
||||
}
|
||||
|
||||
$padType = $cell->getStyle()->getPadByAlign();
|
||||
}
|
||||
|
||||
return \sprintf($cellFormat, str_pad($content, $width, $style->getPaddingChar(), $padType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate number of columns for this table.
|
||||
*/
|
||||
private function calculateNumberOfColumns(array $rows): void
|
||||
{
|
||||
$columns = [0];
|
||||
foreach ($rows as $row) {
|
||||
if ($row instanceof TableSeparator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columns[] = $this->getNumberOfColumns($row);
|
||||
}
|
||||
|
||||
$this->numberOfColumns = max($columns);
|
||||
}
|
||||
|
||||
private function buildTableRows(array $rows): TableRows
|
||||
{
|
||||
/** @var WrappableOutputFormatterInterface $formatter */
|
||||
$formatter = $this->output->getFormatter();
|
||||
$unmergedRows = [];
|
||||
for ($rowKey = 0; $rowKey < \count($rows); ++$rowKey) {
|
||||
$rows = $this->fillNextRows($rows, $rowKey);
|
||||
|
||||
// Remove any new line breaks and replace it with a new line
|
||||
foreach ($rows[$rowKey] as $column => $cell) {
|
||||
$colspan = $cell instanceof TableCell ? $cell->getColspan() : 1;
|
||||
|
||||
$minWrappedWidth = 0;
|
||||
$widthApplied = [];
|
||||
$lengthColumnBorder = $this->getColumnSeparatorWidth() + Helper::width($this->style->getCellRowContentFormat()) - 2;
|
||||
for ($i = $column; $i < ($column + $colspan); ++$i) {
|
||||
if (isset($this->columnMaxWidths[$i])) {
|
||||
$minWrappedWidth += $this->columnMaxWidths[$i];
|
||||
$widthApplied[] = ['type' => 'max', 'column' => $i];
|
||||
} elseif (($this->columnWidths[$i] ?? 0) > 0 && $colspan > 1) {
|
||||
$minWrappedWidth += $this->columnWidths[$i];
|
||||
$widthApplied[] = ['type' => 'min', 'column' => $i];
|
||||
}
|
||||
}
|
||||
if (1 === \count($widthApplied)) {
|
||||
if ($colspan > 1) {
|
||||
$minWrappedWidth *= $colspan; // previous logic
|
||||
}
|
||||
} elseif (\count($widthApplied) > 1) {
|
||||
$minWrappedWidth += (\count($widthApplied) - 1) * $lengthColumnBorder;
|
||||
}
|
||||
|
||||
$cellWidth = Helper::width(Helper::removeDecoration($formatter, $cell));
|
||||
if ($minWrappedWidth && $cellWidth > $minWrappedWidth) {
|
||||
$cell = $formatter->formatAndWrap($cell, $minWrappedWidth);
|
||||
}
|
||||
// update minimal columnWidths for spanned columns
|
||||
if ($colspan > 1 && $minWrappedWidth > 0) {
|
||||
$columnsMinWidthProcessed = [];
|
||||
$cellWidth = min($cellWidth, $minWrappedWidth);
|
||||
foreach ($widthApplied as $item) {
|
||||
if ('max' === $item['type'] && $cellWidth >= $this->columnMaxWidths[$item['column']]) {
|
||||
$minWidthColumn = $this->columnMaxWidths[$item['column']];
|
||||
$this->columnWidths[$item['column']] = $minWidthColumn;
|
||||
$columnsMinWidthProcessed[$item['column']] = true;
|
||||
$cellWidth -= $minWidthColumn + $lengthColumnBorder;
|
||||
}
|
||||
}
|
||||
for ($i = $column; $i < ($column + $colspan); ++$i) {
|
||||
if (isset($columnsMinWidthProcessed[$i])) {
|
||||
continue;
|
||||
}
|
||||
$this->columnWidths[$i] = $cellWidth + $lengthColumnBorder;
|
||||
}
|
||||
}
|
||||
if (!str_contains($cell ?? '', "\n")) {
|
||||
continue;
|
||||
}
|
||||
$eol = str_contains($cell ?? '', "\r\n") ? "\r\n" : "\n";
|
||||
$escaped = implode($eol, array_map(OutputFormatter::escapeTrailingBackslash(...), explode($eol, $cell)));
|
||||
$cell = $cell instanceof TableCell ? new TableCell($escaped, ['colspan' => $cell->getColspan()]) : $escaped;
|
||||
$lines = explode($eol, str_replace($eol, '<fg=default;bg=default></>'.$eol, $cell));
|
||||
foreach ($lines as $lineKey => $line) {
|
||||
if ($colspan > 1) {
|
||||
$line = new TableCell($line, ['colspan' => $colspan]);
|
||||
}
|
||||
if (0 === $lineKey) {
|
||||
$rows[$rowKey][$column] = $line;
|
||||
} else {
|
||||
if (!\array_key_exists($rowKey, $unmergedRows) || !\array_key_exists($lineKey, $unmergedRows[$rowKey])) {
|
||||
$unmergedRows[$rowKey][$lineKey] = $this->copyRow($rows, $rowKey);
|
||||
}
|
||||
$unmergedRows[$rowKey][$lineKey][$column] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new TableRows(function () use ($rows, $unmergedRows): \Traversable {
|
||||
foreach ($rows as $rowKey => $row) {
|
||||
$rowGroup = [$row instanceof TableSeparator ? $row : $this->fillCells($row)];
|
||||
|
||||
if (isset($unmergedRows[$rowKey])) {
|
||||
foreach ($unmergedRows[$rowKey] as $row) {
|
||||
$rowGroup[] = $row instanceof TableSeparator ? $row : $this->fillCells($row);
|
||||
}
|
||||
}
|
||||
yield $rowGroup;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function calculateRowCount(): int
|
||||
{
|
||||
$numberOfRows = \count(iterator_to_array($this->buildTableRows(array_merge($this->headers, [new TableSeparator()], $this->rows))));
|
||||
|
||||
if ($this->headers) {
|
||||
++$numberOfRows; // Add row for header separator
|
||||
}
|
||||
|
||||
if ($this->rows) {
|
||||
++$numberOfRows; // Add row for footer separator
|
||||
}
|
||||
|
||||
return $numberOfRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* fill rows that contains rowspan > 1.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function fillNextRows(array $rows, int $line): array
|
||||
{
|
||||
$unmergedRows = [];
|
||||
foreach ($rows[$line] as $column => $cell) {
|
||||
if (null !== $cell && !$cell instanceof TableCell && !\is_scalar($cell) && !$cell instanceof \Stringable) {
|
||||
throw new InvalidArgumentException(\sprintf('A cell must be a TableCell, a scalar or an object implementing "__toString()", "%s" given.', get_debug_type($cell)));
|
||||
}
|
||||
if ($cell instanceof TableCell && $cell->getRowspan() > 1) {
|
||||
$nbLines = $cell->getRowspan() - 1;
|
||||
$lines = [$cell];
|
||||
if (str_contains($cell, "\n")) {
|
||||
$eol = str_contains($cell, "\r\n") ? "\r\n" : "\n";
|
||||
$lines = explode($eol, str_replace($eol, '<fg=default;bg=default>'.$eol.'</>', $cell));
|
||||
$nbLines = \count($lines) > $nbLines ? substr_count($cell, $eol) : $nbLines;
|
||||
|
||||
$rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
|
||||
unset($lines[0]);
|
||||
}
|
||||
|
||||
// create a two dimensional array (rowspan x colspan)
|
||||
$unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, []), $unmergedRows);
|
||||
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
|
||||
$value = $lines[$unmergedRowKey - $line] ?? '';
|
||||
$unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
|
||||
if ($nbLines === $unmergedRowKey - $line) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
|
||||
// we need to know if $unmergedRow will be merged or inserted into $rows
|
||||
if (isset($rows[$unmergedRowKey]) && \is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRow) <= $this->numberOfColumns)) {
|
||||
foreach ($unmergedRow as $cellKey => $cell) {
|
||||
// insert cell into row at cellKey position
|
||||
array_splice($rows[$unmergedRowKey], $cellKey, 0, [$cell]);
|
||||
}
|
||||
} else {
|
||||
$row = $this->copyRow($rows, $unmergedRowKey - 1);
|
||||
foreach ($unmergedRow as $column => $cell) {
|
||||
if ($cell) {
|
||||
$row[$column] = $cell;
|
||||
}
|
||||
}
|
||||
array_splice($rows, $unmergedRowKey, 0, [$row]);
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* fill cells for a row that contains colspan > 1.
|
||||
*/
|
||||
private function fillCells(iterable $row): iterable
|
||||
{
|
||||
$newRow = [];
|
||||
|
||||
foreach ($row as $column => $cell) {
|
||||
$newRow[] = $cell;
|
||||
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
|
||||
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) {
|
||||
// insert empty value at column position
|
||||
$newRow[] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $newRow ?: $row;
|
||||
}
|
||||
|
||||
private function copyRow(array $rows, int $line): array
|
||||
{
|
||||
$row = $rows[$line];
|
||||
foreach ($row as $cellKey => $cellValue) {
|
||||
$row[$cellKey] = '';
|
||||
if ($cellValue instanceof TableCell) {
|
||||
$row[$cellKey] = new TableCell('', ['colspan' => $cellValue->getColspan()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets number of columns by row.
|
||||
*/
|
||||
private function getNumberOfColumns(array $row): int
|
||||
{
|
||||
$columns = \count($row);
|
||||
foreach ($row as $column) {
|
||||
$columns += $column instanceof TableCell ? ($column->getColspan() - 1) : 0;
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of columns for the given row.
|
||||
*/
|
||||
private function getRowColumns(array $row): array
|
||||
{
|
||||
$columns = range(0, $this->numberOfColumns - 1);
|
||||
foreach ($row as $cellKey => $cell) {
|
||||
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
|
||||
// exclude grouped columns.
|
||||
$columns = array_diff($columns, range($cellKey + 1, $cellKey + $cell->getColspan() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates columns widths.
|
||||
*/
|
||||
private function calculateColumnsWidth(iterable $groups): void
|
||||
{
|
||||
for ($column = 0; $column < $this->numberOfColumns; ++$column) {
|
||||
$lengths = [];
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group as $row) {
|
||||
if ($row instanceof TableSeparator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($row as $i => $cell) {
|
||||
if ($cell instanceof TableCell) {
|
||||
$textContent = Helper::removeDecoration($this->output->getFormatter(), $cell);
|
||||
$textLength = Helper::width($textContent);
|
||||
if ($textLength > 0) {
|
||||
$contentColumns = mb_str_split($textContent, ceil($textLength / $cell->getColspan()));
|
||||
foreach ($contentColumns as $position => $content) {
|
||||
$row[$i + $position] = $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$lengths[] = $this->getCellWidth($row, $column);
|
||||
}
|
||||
}
|
||||
|
||||
$this->effectiveColumnWidths[$column] = max($lengths) + Helper::width($this->style->getCellRowContentFormat()) - 2;
|
||||
}
|
||||
}
|
||||
|
||||
private function getColumnSeparatorWidth(): int
|
||||
{
|
||||
return Helper::width(\sprintf($this->style->getBorderFormat(), $this->style->getBorderChars()[3]));
|
||||
}
|
||||
|
||||
private function getCellWidth(array $row, int $column): int
|
||||
{
|
||||
$cellWidth = 0;
|
||||
|
||||
if (isset($row[$column])) {
|
||||
$cell = $row[$column];
|
||||
$cellWidth = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $cell));
|
||||
}
|
||||
|
||||
$columnWidth = $this->columnWidths[$column] ?? 0;
|
||||
$cellWidth = max($cellWidth, $columnWidth);
|
||||
|
||||
return isset($this->columnMaxWidths[$column]) ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after rendering to cleanup cache data.
|
||||
*/
|
||||
private function cleanup(): void
|
||||
{
|
||||
$this->effectiveColumnWidths = [];
|
||||
unset($this->numberOfColumns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, TableStyle>
|
||||
*/
|
||||
private static function initStyles(): array
|
||||
{
|
||||
$markdown = new TableStyle();
|
||||
$markdown
|
||||
->setDefaultCrossingChar('|')
|
||||
->setDisplayOutsideBorder(false)
|
||||
;
|
||||
|
||||
$borderless = new TableStyle();
|
||||
$borderless
|
||||
->setHorizontalBorderChars('=')
|
||||
->setVerticalBorderChars(' ')
|
||||
->setDefaultCrossingChar(' ')
|
||||
;
|
||||
|
||||
$compact = new TableStyle();
|
||||
$compact
|
||||
->setHorizontalBorderChars('')
|
||||
->setVerticalBorderChars('')
|
||||
->setDefaultCrossingChar('')
|
||||
->setCellRowContentFormat('%s ')
|
||||
;
|
||||
|
||||
$styleGuide = new TableStyle();
|
||||
$styleGuide
|
||||
->setHorizontalBorderChars('-')
|
||||
->setVerticalBorderChars(' ')
|
||||
->setDefaultCrossingChar(' ')
|
||||
->setCellHeaderFormat('%s')
|
||||
;
|
||||
|
||||
$box = (new TableStyle())
|
||||
->setHorizontalBorderChars('─')
|
||||
->setVerticalBorderChars('│')
|
||||
->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├')
|
||||
;
|
||||
|
||||
$boxDouble = (new TableStyle())
|
||||
->setHorizontalBorderChars('═', '─')
|
||||
->setVerticalBorderChars('║', '│')
|
||||
->setCrossingChars('┼', '╔', '╤', '╗', '╢', '╝', '╧', '╚', '╟', '╠', '╪', '╣')
|
||||
;
|
||||
|
||||
return [
|
||||
'default' => new TableStyle(),
|
||||
'markdown' => $markdown,
|
||||
'borderless' => $borderless,
|
||||
'compact' => $compact,
|
||||
'symfony-style-guide' => $styleGuide,
|
||||
'box' => $box,
|
||||
'box-double' => $boxDouble,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveStyle(TableStyle|string $name): TableStyle
|
||||
{
|
||||
if ($name instanceof TableStyle) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return self::$styles[$name] ?? throw new InvalidArgumentException(\sprintf('Style "%s" is not defined.', $name));
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?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\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
|
||||
*/
|
||||
class TableCell
|
||||
{
|
||||
private array $options = [
|
||||
'rowspan' => 1,
|
||||
'colspan' => 1,
|
||||
'style' => null,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private string $value = '',
|
||||
array $options = [],
|
||||
) {
|
||||
// check option names
|
||||
if ($diff = array_diff(array_keys($options), array_keys($this->options))) {
|
||||
throw new InvalidArgumentException(\sprintf('The TableCell does not support the following options: \'%s\'.', implode('\', \'', $diff)));
|
||||
}
|
||||
|
||||
if (isset($options['style']) && !$options['style'] instanceof TableCellStyle) {
|
||||
throw new InvalidArgumentException('The style option must be an instance of "TableCellStyle".');
|
||||
}
|
||||
|
||||
$this->options = array_merge($this->options, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell value.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets number of colspan.
|
||||
*/
|
||||
public function getColspan(): int
|
||||
{
|
||||
return (int) $this->options['colspan'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets number of rowspan.
|
||||
*/
|
||||
public function getRowspan(): int
|
||||
{
|
||||
return (int) $this->options['rowspan'];
|
||||
}
|
||||
|
||||
public function getStyle(): ?TableCellStyle
|
||||
{
|
||||
return $this->options['style'];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user