src/Console.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Core\DebugTrait;
use Atk4\Core\TraitUtil;
use Atk4\Ui\Js\JsBlock;
use Atk4\Ui\Js\JsExpressionable;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Console is a black square component resembling terminal window. It can be programmed
* to run a job and output results to the user.
*/
class Console extends View implements LoggerInterface
{
public $ui = 'inverted black segment';
/**
* Specify which event will trigger this console. Set to false
* to disable automatic triggering if you need to trigger it
* manually.
*
* @var bool
*/
public $event = true;
/**
* Will be set to $true while executing callback. Some methods
* will use this to automatically schedule their own callback
* and allowing you a cleaner syntax, such as.
*
* $console->setModel($user, 'generateReport');
*
* @var bool
*/
protected $sseInProgress = false;
/** @var JsSse|null Stores object JsSse which is used for communication. */
public $sse;
/**
* Bypass is used internally to capture and wrap direct output, but prevent JsSse from
* triggering output recursively.
*
* @var bool
*/
protected $_outputBypass = false;
/** @var int|null */
public $lastExitCode;
/**
* Set a callback method which will be executed with the output sent back to the terminal.
*
* Argument passed to your callback will be $this Console. You may perform calls
* to methods such as
*
* $console->output()
* $console->outputHtml()
*
* If you are using setModel, and if your model implements \Atk4\Core\DebugTrait,
* then you you will see debug information generated by $this->debug() or $this->log().
*
* This intercepts default application logging for the duration of the process.
*
* If you are using runCommand, then server command will be executed with it's output
* (stdout and stderr) redirected to the console.
*
* While inside a callback you may execute runCommand or setModel multiple times.
*
* @param \Closure($this): void $fx callback which will be executed while displaying output inside console
* @param bool|string $event "true" would mean to execute on page load, string would indicate
* JS event. See first argument for View::js()
*/
#[\Override]
public function set($fx = null, $event = null)
{
if (!$fx instanceof \Closure) {
throw new \TypeError('$fx must be of type Closure');
}
if ($event !== null) {
$this->event = $event;
}
if (!$this->sse) {
$this->sse = JsSse::addTo($this);
}
$this->sse->set(function () use ($fx) {
$this->sseInProgress = true;
$oldLogger = $this->getApp()->logger;
$this->getApp()->logger = $this;
try {
ob_start(function (string $content) {
if ($this->_outputBypass || $content === '' /* needed as self::output() adds NL */) {
return $content;
}
$output = '';
$this->sse->echoFunction = static function (string $str) use (&$output) {
$output .= $str;
};
$this->output($content);
$this->sse->echoFunction = false;
return $output;
}, 1);
try {
$fx($this);
} catch (\Throwable $e) {
$this->outputHtmlWithoutPre('<div class="ui segment">{ex}</div>', ['ex' => $this->getApp()->renderExceptionHtml($e)]);
}
} finally {
$this->sseInProgress = false;
$this->getApp()->logger = $oldLogger;
}
});
if ($this->event) {
$this->js($this->event, $this->jsExecute());
}
return $this;
}
public function jsExecute(): JsBlock
{
return $this->sse->jsExecute();
}
private function escapeOutputHtml(string $message): string
{
$res = $this->getApp()->encodeHtml($message);
// TODO assert with Behat test
// fix new lines for display and copy paste, testcase:
// $genFx = function (array $values, int $maxLength, array $prev = null) use (&$genFx) {
// $res = [];
// foreach ($prev ?? [''] as $p) {
// foreach ($values as $v) {
// $res[] = $p . $v;
// }
// }
//
// if (--$maxLength > 0) {
// $res = array_merge($res, $genFx($values, $maxLength, $res));
// }
//
// if ($prev === null) {
// array_unshift($res, '');
// }
//
// return $res;
// };
// $testCases = $genFx([' ', "\t", "\n", 'x'], 5);
//
// foreach ($testCases as $testCase) {
// $this->output('--------' . str_replace([' ', "\t", "\n", 'x'], [' sp', ' tab', ' nl', ' x'], $testCase));
// $this->output($testCase);
// }
// $this->output('--------');
$res = preg_replace('~\r\n?|\n~s', "\n", $res);
$res = preg_replace('~^$|(?<!^)(\n+)$~s', "$1\n", $res);
return $res;
}
/**
* Output a single line to the console.
*
* @param array<string, string> $context
*
* @return $this
*/
public function output(string $message, array $context = [])
{
$this->outputHtml($this->escapeOutputHtml($message), $context);
return $this;
}
/**
* Output unescaped HTML to the console.
*
* @param array<string, string> $context
*
* @return $this
*/
public function outputHtml(string $messageHtml, array $context = [])
{
$this->outputHtmlWithoutPre($this->getApp()->getTag('div', ['style' => 'font-family: monospace; white-space: pre;'], [$messageHtml]), $context);
return $this;
}
/**
* Output unescaped HTML to the console without wrapping in <pre>.
*
* @param array<string, string> $context
*
* @return $this
*/
protected function outputHtmlWithoutPre(string $messageHtml, array $context = [])
{
$messageHtml = preg_replace_callback('~{([\w]+)}~', static function ($matches) use ($context) {
if (isset($context[$matches[1]])) {
return $context[$matches[1]];
}
return $matches[0];
}, $messageHtml);
$this->_outputBypass = true;
try {
$this->sse->send($this->js()->append($messageHtml));
} finally {
$this->_outputBypass = false;
}
return $this;
}
#[\Override]
protected function renderView(): void
{
$this->setStyle('overflow-x', 'auto');
parent::renderView();
}
/**
* Executes a JavaScript action.
*
* @return $this
*/
public function send(JsExpressionable $js)
{
$this->_outputBypass = true;
try {
$this->sse->send($js);
} finally {
$this->_outputBypass = false;
}
return $this;
}
/**
* Executes command passing along escaped arguments.
*
* Will also stream stdout / stderr as the command executes.
* once command terminates method will return the exit code.
*
* This method can be executed from inside callback or
* without it.
*
* Example: $console->exec('ping', ['-c', '5', '8.8.8.8']);
*
* All arguments are escaped.
*
* @param list<string|int> $args
*/
public function exec(string $command, array $args = []): ?bool
{
if (!$this->sseInProgress) {
$this->set(function () use ($command, $args) {
$this->output(
'--[ Executing ' . $command
. ($args !== [] ? ' with ' . count($args) . ' arguments' : '')
. ' ]--------------'
);
$this->exec($command, $args);
$this->output('--[ Exit code: ' . $this->lastExitCode . ' ]------------');
});
return null;
}
[$proc, $pipes] = $this->execRaw($command, $args);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
// $pipes contain streams that are still open and not EOF
while ($pipes) { // @TODO this condition is always true
$read = $pipes;
$j1 = null;
$j2 = null;
if (stream_select($read, $j1, $j2, 2) === false) {
throw new Exception('Unexpected stream_select() result');
}
$status = proc_get_status($proc);
if (!$status['running']) {
proc_close($proc);
break;
}
foreach ($read as $f) {
$data = rtrim((string) fgets($f));
if ($data === '') {
// TODO fix coverage stability, add test with explicit empty string
// @codeCoverageIgnoreStart
continue;
// @codeCoverageIgnoreEnd
}
if ($f === $pipes[2]) { // stderr
$this->warning($data);
} else { // stdout
$this->output($data);
}
}
}
$this->lastExitCode = $status['exitcode'];
return $this->lastExitCode ? false : true;
}
/**
* @param list<string|int> $args
*
* @return array{resource, non-empty-array<int, resource>}
*/
protected function execRaw(string $command, array $args = [])
{
$args = array_map(static fn ($v) => escapeshellarg($v), $args);
$spec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; // we want stdout and stderr
$pipes = null;
$proc = proc_open($command . ' ' . implode(' ', $args), $spec, $pipes);
if (!is_resource($proc)) {
throw (new Exception('Command failed to execute'))
->addMoreInfo('command', $command)
->addMoreInfo('args', $args);
}
return [$proc, $pipes];
}
/**
* Execute method of a certain object. If object uses Atk4\Core\DebugTrait,
* then debugging will also be used.
*
* During the invocation, Console will substitute $app->logger with itself,
* capturing all debug/info/log messages generated by your code and displaying
* it inside console.
*
* // runs $userController->generateReport('pdf')
* Console::addTo($app)->runMethod($userController, 'generateReports', ['pdf']);
*
* // runs PainFactory::lastStaticMethod()
* Console::addTo($app)->runMethod('PainFactory', 'lastStaticMethod');
*
* To produce output:
* - use $this->debug() or $this->info() (see documentation on DebugTrait)
*
* NOTE: debug() method will only output if you set debug=true. That is done
* for the $userController automatically, but for any nested objects you would have
* to pass on the property.
*
* @param object|class-string $object
* @param list<mixed> $args
*
* @return $this
*/
public function runMethod($object, string $method, array $args = [])
{
if (!$this->sseInProgress) {
$this->set(function () use ($object, $method, $args) {
$this->runMethod($object, $method, $args);
});
return $this;
}
if (is_object($object)) {
// temporarily override app logging
if (TraitUtil::hasAppScopeTrait($object) && $object->issetApp()) {
$loggerBak = $object->getApp()->logger;
$object->getApp()->logger = $this;
}
if (TraitUtil::hasTrait($object, DebugTrait::class)) {
$debugBak = $object->debug;
$object->debug = true;
}
$this->output('--[ Executing ' . get_class($object) . '->' . $method . ' ]--------------');
try {
$result = $object->{$method}(...$args);
} finally {
if (TraitUtil::hasAppScopeTrait($object) && $object->issetApp()) {
$object->getApp()->logger = $loggerBak; // @phpstan-ignore variable.undefined
}
if (TraitUtil::hasTrait($object, DebugTrait::class)) {
$object->debug = $debugBak;
}
}
} else {
$this->output('--[ Executing ' . $object . '::' . $method . ' ]--------------');
$result = $object::{$method}(...$args);
}
$this->output('--[ Result: ' . $this->getApp()->encodeJson($result) . ' ]------------');
return $this;
}
#[\Override]
public function emergency($message, array $context = []): void
{
$this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function alert($message, array $context = []): void
{
$this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function critical($message, array $context = []): void
{
$this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function error($message, array $context = []): void
{
$this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function warning($message, array $context = []): void
{
$this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function notice($message, array $context = []): void
{
$this->outputHtml('<font color="yellow">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function info($message, array $context = []): void
{
$this->outputHtml('<font color="gray">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
#[\Override]
public function debug($message, array $context = []): void
{
$this->outputHtml('<font color="cyan">' . $this->escapeOutputHtml($message) . '</font>', $context);
}
/**
* @param LogLevel::* $level
*/
#[\Override]
public function log($level, $message, array $context = []): void
{
$this->{$level}($message, $context);
}
}