470 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			470 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
namespace NoccyLabs\Shell;
 | 
						|
 | 
						|
use NoccyLabs\Shell\LineRead;
 | 
						|
use NoccyLabs\TinyEvent\EventEmitterTrait;
 | 
						|
use NoccyLabs\TinyEvent\Event;
 | 
						|
 | 
						|
class Shell
 | 
						|
{
 | 
						|
    use EventEmitterTrait;
 | 
						|
 | 
						|
    const EVT_UPDATE_PROMPT     = "shell.prompt";   // called to update the prompt
 | 
						|
    const EVT_BEFORE_COMMAND    = "shell.command.before"; // before a command is executed
 | 
						|
    const EVT_AFTER_COMMAND     = "shell.command.after"; // after a command is executed
 | 
						|
    const EVT_NO_COMMAND        = "shell.command.missing"; // no such command found
 | 
						|
    const EVT_CONTEXT_CHANGED   = "shell.context";  // a new context is activated
 | 
						|
    const EVT_SHELL_START       = "shell.start";    // the shell is about to start
 | 
						|
    const EVT_SHELL_STOP        = "shell.stop";     // the shell is about to exit
 | 
						|
    const EVT_SHELL_ABORT       = "shell.abort";    // the shell was aborted (ctrl-c)
 | 
						|
    const EVT_SHELL_ESCAPE      = "shell.escape";   // escape key pressed
 | 
						|
 | 
						|
    /**
 | 
						|
     * @var LineRead The lineread instance
 | 
						|
     */
 | 
						|
    protected $lineReader = null;
 | 
						|
    /**
 | 
						|
     * @var Context The current context
 | 
						|
     */
 | 
						|
    protected $context = null;
 | 
						|
    /**
 | 
						|
     * @var Context[] The stack of parent contexts
 | 
						|
     */
 | 
						|
    protected $contextStack = [];
 | 
						|
    /**
 | 
						|
     * @var object[] Created timers
 | 
						|
     */    
 | 
						|
    protected $timers = [];
 | 
						|
    /** 
 | 
						|
     * @var object[] Running subtasks
 | 
						|
     */
 | 
						|
    protected $tasks = [];
 | 
						|
    /**
 | 
						|
     * @var string The prompt string
 | 
						|
     */
 | 
						|
    protected $prompt = ">";
 | 
						|
    /**
 | 
						|
     * @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;
 | 
						|
 | 
						|
    /**
 | 
						|
     * 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;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * 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;
 | 
						|
        $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);
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * 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->executeBuiltin($command, ...$args)) {
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        // Call the handler if the command was found
 | 
						|
        if (($target = $this->findCommand($command))) {
 | 
						|
            $ret = $target(...$args);
 | 
						|
            if ($ret instanceof Context) {
 | 
						|
                $this->pushContext($ret);
 | 
						|
            }
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        // Call 'execute' on the current context
 | 
						|
        if ($this->context->execute($command, ...$args)) {
 | 
						|
            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);
 | 
						|
                //printf("Commands in current context:\n");
 | 
						|
                foreach ($help as $command=>$info) {
 | 
						|
                    printf("  %-20s  %s\n", $command, $info);
 | 
						|
                }
 | 
						|
                if (count($ghelp)) {
 | 
						|
                    //printf("\nImported from parent contexts:\n");
 | 
						|
                    foreach ($ghelp as $command=>$info) {
 | 
						|
                        printf("  %-20s  %s\n", $command, $info);
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                printf("\nGlobal commands:\n");
 | 
						|
                printf("  %-20s  %s\n", "exit", "Leave the shell");
 | 
						|
                printf("  %-20s  %s\n", "..", "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 $task) {
 | 
						|
                    $task->update();
 | 
						|
                }
 | 
						|
                if ($buffer) {
 | 
						|
                    $this->executeBuffer($buffer);
 | 
						|
                }
 | 
						|
                $output = ob_get_contents();
 | 
						|
                ob_end_clean();
 | 
						|
 | 
						|
                if (trim($output)) {
 | 
						|
                    $this->lineReader->erase();
 | 
						|
                    echo rtrim($output)."\n";
 | 
						|
                    $this->lineReader->redraw();
 | 
						|
                }
 | 
						|
 | 
						|
                if (!$this->context) {
 | 
						|
                    $this->stop();
 | 
						|
                }
 | 
						|
 | 
						|
                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);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Stop the shell; calling this method will cause the main run() to return.
 | 
						|
     *
 | 
						|
     */
 | 
						|
    public function stop()
 | 
						|
    {
 | 
						|
        $this->running = false;
 | 
						|
    }
 | 
						|
}
 |