"; /** * @var Style The style applied to the prompt */ protected $prompt_style = null; /** * @var Style The style applied to the input text */ protected $input_style = null; /** * @var callable The callback to pass the input on to */ protected $input_callback = null; /** * @var string Question prompt for getInput() */ protected $input_prompt = null; /** * @var string The prompt before changing to $input_prompt */ protected $input_last_prompt = null; /** * Constructor * */ public function __construct() { $t = $this; register_shutdown_function(function () use (&$t) { if ($t) unset($t); }); } /** * Destructor * */ public function __destruct() { if ($this->lineReader) { $this->lineReader = null; } } /** * Return the current context * * @return Context The current context */ public function getContext() { return $this->context; } /** * Push a new primary context, saving the previous contexts on a stack. * * @param Context $context */ public function pushContext(Context $context) { if ($this->context) { $context->setParent($this->context); array_unshift($this->contextStack, $this->context); } $context->setShell($this); $this->context = $context; $context->onEnter(); $this->dispatchEvent(self::EVT_CONTEXT_CHANGED); } /** * Pop the current context. * * @return Context */ public function popContext() { $previous = $this->context; if (count($this->contextStack)>0) { $this->context = array_shift($this->contextStack); } else { $this->context = null; } $this->dispatchEvent(self::EVT_CONTEXT_CHANGED); return $previous; } public function getContextPath($separator=":") { // Return null if we don't have a current context if (!$this->context) return null; // Assemble the contexts to walk $stack = [ $this->context->getName() ]; foreach ($this->contextStack as $context) { $stack[] = $context->getName(); } // Reverse the order to make it more logical $stack = array_reverse($stack); return join($separator,$stack); } /** * Create a new empty context and push it on the stack. * * @param string $name The name of the context to create * @return Context The created context */ public function createContext($name) { $context = new Context($name); $this->pushContext($context); return $context; } /** * Add a callback to be called at a regular interval during the update phase. * Adding timers with a low interval (less than 200 i.e. 0.2s) may have an * impact on performance. * * @param int $interval Interval in ms * @param callable $handler The handler * @param array $userdata Data to be passed to the handler * @return Timer */ public function addTimer($interval, callable $handler, array $userdata=[]) { $timer = new class($interval, $handler, $userdata) { private $next; private $interval; private $handler; private $userdata; public function __construct($interval, callable $handler, array $userdata) { $this->interval = $interval / 1000; $this->next = microtime(true) + $this->interval; $this->handler = $handler; $this->userdata = $userdata; } public function update() { $now = microtime(true); if ($now > $this->next) { $this->next = $now + $this->interval; call_user_func($this->handler, $this->userdata); } } }; $this->timers[] = $timer; return $timer; } /** * Remove a created timer. * * @param Timer $timer */ public function removeTimer($timer) { $this->timers = array_filter($this->timers, function ($v) use ($timer) { return ($v !== $timer); }); } /** * Add a task to be update():d in the main loop. * * @param TaskInterface $task The task to add */ public function addTask(TaskInterface $task) { if ($this->dispatchEvent(self::EVT_TASK_CREATED, [ 'task' => $task ])->isPropagationStopped()) { return; } $this->tasks[] = $task; } /** * Remove a previously added task. This can also be done by the task returning * false from its isValid() method. * * @param TaskInterface $task The task to remove */ public function removeTask(TaskInterface $task) { $this->tasks = array_filter($this->tasks, function ($item) use ($task) { return ($item != $task); }); $this->dispatchEvent(self::EVT_TASK_DESTROYED, [ 'task' => $task ]); } /** * Set the prompt text * * @param string $text The text */ public function setPrompt($text) { $this->prompt = $text; if ($this->lineReader) { $this->lineReader->setPromptText($text); } } /** * Set the prompt style * * @param Style $style The style to apply to the prompt */ public function setPromptStyle(Style $style) { $this->prompt_style = $style; if ($this->lineReader) { $this->lineReader->setPromptStyle($style); } } /** * Set the input style * * @param Style $style The style to apply to the text */ public function setInputStyle(Style $style) { $this->input_style = $style; if ($this->lineReader) { $this->lineReader->setCommandStyle($style); } } /** * Find a command and return a closure. * * @return callable The command */ private function findCommand($command) { // Go over current context and walk through stack until finding command if ($this->context->hasCommand($command)) { $handler = $this->context->getCommand($command); } else { foreach($this->contextStack as $context) { if ($context->hasCommand($command) && $context->isCommandGlobal($command)) { $handler = $context->getCommand($command); break; } } } // No handler... if (empty($handler)) { return false; } // Return closure return function (...$args) use ($handler) { return call_user_func($handler, ...$args); }; } /** * Execute a command with arguments. * * @param string $command The command name to execute * @param string ...$args Arguments * @return mixed * @throws Exception\BadCommandExcception */ public function executeCommand($command, ...$args) { if ($this->dispatchEvent(self::EVT_BEFORE_COMMAND, [ 'command' => $command, 'args' => $args ])->isPropagationStopped()) { return true; } if ($this->executeBuiltin($command, ...$args)) { $this->dispatchEvent(self::EVT_AFTER_COMMAND, [ 'command' => $command, 'args' => $args, 'type' => 'builtin' ]); return; } // Call the handler if the command was found if (($target = $this->findCommand($command))) { $ret = $target(...$args); if ($ret instanceof Context) { $this->pushContext($ret); } $this->dispatchEvent(self::EVT_AFTER_COMMAND, [ 'command' => $command, 'args' => $args, 'type' => 'command' ]); return; } // Call 'execute' on the current context if ($this->context->execute($command, ...$args)) { $this->dispatchEvent(self::EVT_AFTER_COMMAND, [ 'command' => $command, 'args' => $args, 'type' => 'execute' ]); return; } // Fire the EVT_BAD_COMMAND event and return if the event propagation // has been stopped. $evt = $this->dispatchEvent(self::EVT_BAD_COMMAND, [ 'command'=>$command, 'args'=>$args ]); if ($evt->isPropagationStopped()) { return; } // Throw error if the command could not be found throw new Exception\BadCommandException("Command {$command} not found"); } /** * Execute a built-in command * * @param string $command Command name * @param mixed ...$args Arguments * @return bool True if the command was handled OK */ public function executeBuiltin($command, ...$args) { switch ($command) { case '.': $type = basename(strtr(get_class($this->context), "\\", "/")); printf("%s<%s>: %s\n", $type, $this->context->getName(), $this->context->getContextInfo()); $level = 0; foreach ($this->contextStack as $context) { $type = basename(strtr(get_class($context), "\\", "/")); printf(" %sā””ā”€%s<%s>: %s\n", str_repeat(" ",$level++), $type, $context->getName(), $context->getContextInfo()); } break; case '..': if (count($this->contextStack)>0) $this->popContext(); break; case 'help': $help = $this->context->getCommandHelp(); $ghelp = []; foreach ($this->contextStack as $context) { $commands = $context->getCommandHelp(); foreach ($commands as $command=>$info) { if (strpos(" ",$command)!==false) { list ($cmd,$arg)=explode(" ",$command,2); } else { $cmd = $command; } if ($context->isCommandGlobal($cmd)) { $ghelp[$command] = $info; } } } ksort($ghelp); $_ = function($command,$args,$info) { printf(" \e[96m%s\e[0m \e[0;3m%s\e[0m \e[30G\e[36m%s\e[0m\n", $command, $args, $info); }; printf("\e[1mCommands:\e[0m\n"); foreach ($help as $command=>$info) { if (strpos($command," ")!==false) { list($command,$args) = explode(" ",$command,2); } else $args=null; $_($command, $args,$info); } if (count($ghelp)) { printf("\e[1mCommands from parent contexts:\e[0m\n"); if (strpos($command," ")!==false) { list($command,$args) = explode(" ",$command,2); } else $args=null; $_($command, $args,$info); } printf("\e[1mGlobal commands:\e[0m\n"); $_("exit", null, "Leave the shell"); $_(".", null, "Show the context tree"); $_("..", null, "Discard the current context and go to parent"); break; case 'exit': $this->stop(); break; default: return false; } return true; } /** * Parse a string and execute the resulting command. * * @param string $command The string to parse * @return mixed */ public function executeBuffer(string $command) { $args = str_getcsv($command, " ", "\"", "\\"); $command = array_shift($args); try { $this->executeCommand($command, ...$args); } catch (Exception\ShellException $e) { echo "\e[31;91;1m{$e->getMessage()}\e[0m\n"; } } /** * Start the shell * */ public function run() { try { $this->lineReader = new LineRead(); $this->lineReader->setPromptText($this->prompt); $this->lineReader->setPromptStyle($this->prompt_style?:new Style(Style::BR_GREEN)); $this->lineReader->setCommandStyle($this->input_style?:new Style(Style::GREEN)); $this->running = true; $this->dispatchEvent(self::EVT_UPDATE_PROMPT); $this->dispatchEvent(self::EVT_SHELL_START); while ($this->running) { // Update the input stuff, sleep if nothing to do. if (!($buffer = $this->lineReader->update())) { usleep(10000); } // Escape is handy too... if ($buffer == "\e") { $this->dispatchEvent(self::EVT_SHELL_ESCAPE); continue; } // we get a ^C on ^C, so deal with the ^C. if ($buffer == "\x03") { $this->dispatchEvent(self::EVT_SHELL_ABORT); $this->stop(); continue; } // Execute the buffer ob_start(); $this->dispatchEvent("update"); foreach ($this->timers as $timer) { $timer->update(); } foreach ($this->tasks as $taskidx=>$task) { $task->update(); if (!$task->isValid()) { $this->dispatchEvent(self::EVT_TASK_DESTROYED, [ 'task' => $task ]); unset($this->tasks[$taskidx]); } } if ($buffer) { $this->executeBuffer($buffer); } $output = ob_get_contents(); ob_end_clean(); if (trim($output)) { $this->lineReader->erase(); echo rtrim($output)."\n"; if ($this->running) $this->lineReader->redraw(); } if (!$this->context) { $this->stop(); break; } if ($buffer) { $this->dispatchEvent(self::EVT_UPDATE_PROMPT); } } } catch (\Exception $e) { fprintf(STDERR, "\e[31;1mFatal: Unhandled exception\e[0m\n\n%s\n", $e); } $this->dispatchEvent(self::EVT_SHELL_STOP); $this->lineReader = null; } /** * Helper function to emit an event with shell instance and context included. * * @param string $type The event type * @param array[] $data The userdata of the event */ private function dispatchEvent($type, array $data=[]) { $data['shell'] = $this; $data['context'] = $this->context; $event = new Event($type, $data); $this->emitEvent($type, $event); return $event; } public function getInput($prompt, callable $callback) { $this->addListener(Shell::EVT_UPDATE_PROMPT, [ $this, "onInputPrompt" ], $prompt); $this->addListener(Shell::EVT_BEFORE_COMMAND, [ $this, "onInputHandler" ], $this->prompt, $callback); } public function onInputPrompt(Event $e, $prompt) { $this->setPrompt($prompt); $e->stopPropagation(); $this->removeListener(Shell::EVT_UPDATE_PROMPT, [ $this, "onInputPrompt" ], $prompt); } public function onInputHandler(Event $e, $last_prompt, $callback) { // Restore the prompt $this->setPrompt($last_prompt); // Remove the listeners and compose the result string $this->removeListener(Shell::EVT_BEFORE_COMMAND, [ $this, "onInputHandler" ], $last_prompt, $callback); $input = trim($e->command." ".join(" ",$e->args)); $e->stopPropagation(); // Call the callback call_user_func($callback, $input); } /** * Stop the shell; calling this method will cause the main run() to return. * */ public function stop() { $this->running = false; } }