diff --git a/examples/contexts.php b/examples/contexts.php new file mode 100644 index 0000000..424567c --- /dev/null +++ b/examples/contexts.php @@ -0,0 +1,27 @@ +addListener("prompt", function ($event, $shell) { + $name = $shell->getContextPath(); + $shell->setPrompt("shell{$name}> "); +}); + +$root = new Context(); +$root->addCommand("test", function () { + echo "It works!\n"; +}); +$root->addCommand("context", function ($name) { + $context = new Context($name); + $context->name = $name; + return $context; +}); +$shell->pushContext($root); + + +$shell->run(); + diff --git a/examples/simple.php b/examples/simple.php index acd1d07..3fbdf4a 100644 --- a/examples/simple.php +++ b/examples/simple.php @@ -7,13 +7,9 @@ use NoccyLabs\Shell\Command; class MyCommand extends Command { - public function getName() - { - return "mycommand"; - } public function execute() { - $this->writeln("Executing command"); + echo "Executing command"; } } @@ -22,20 +18,19 @@ class MyShell extends Shell protected $seq = 0; - protected function configure(array $config) + protected function configure() { - $this->addCommand("hello", function () { - echo "world\n\rthis\n\ris\n\ra\ntest\n\r"; + $context = $this->createContext(); + $context->addCommand("hello", function () { + echo "world\nthis\nis\na\ntest\n"; }); - $this->addCommand(new MyCommand()); + $context->addCommand("mycommand", new MyCommand()); $this->updatePrompt(); } protected function updatePrompt() { $this->setPrompt("test[{$this->seq}]: "); - $fg = ($this->seq % 7) + 1; - $this->setPromptStyle("3{$fg}"); } protected function onUpdate() diff --git a/lib/Command.php b/lib/Command.php index ce036fe..6de03e0 100644 --- a/lib/Command.php +++ b/lib/Command.php @@ -23,7 +23,8 @@ abstract class Command $this->shell->writeln($str); } - abstract public function getName(); + public function getName() + {} public function getDescription() {} @@ -31,8 +32,8 @@ abstract class Command public function getHelp() {} - public function run(array $args) + public function __invoke(...$args) { - call_user_func_array([$this,"execute"], $args); + call_user_func([$this,"execute"], ...$args); } } diff --git a/lib/Context.php b/lib/Context.php new file mode 100644 index 0000000..ab7d0ce --- /dev/null +++ b/lib/Context.php @@ -0,0 +1,86 @@ +name = $name; + $this->data = $data; + $this->configure(); + } + + protected function configure() + { + // Override this to do setup stuff + } + + public function addCommand($command, callable $handler) + { + $this->commands[$command] = $handler; + } + + public function addCommands(array $commands) + { + foreach ($commands as $command=>$handler) { + // Make it easier to connect commands direct to local functions + if (is_string($handler) && is_callable([$this,$handler])) { + $handler = [ $this,$handler ]; + } + // Add the command to the command list + $this->addCommand($handler); + } + } + + public function hasCommand($command) + { + return array_key_exists($command, $this->commands); + } + + public function getCommand($command) + { + return $this->commands[$command]; + } + + public function getName() + { + return $this->name; + } + + public function __get($key) + { + if (!array_key_exists($key,$this->data)) { + return false; + } + return $this->data[$key]; + } + + public function __set($key,$value) + { + $this->data[$key] = $value; + } + + public function __isset($key) + { + return array_key_exists($key); + } + + public function __unset($key) + { + unset($this->data[$key]); + } + + public function getData() + { + return $this->data; + } + +} \ No newline at end of file diff --git a/lib/Exception/BadCommandException.php b/lib/Exception/BadCommandException.php new file mode 100644 index 0000000..f6c2922 --- /dev/null +++ b/lib/Exception/BadCommandException.php @@ -0,0 +1,6 @@ +sttyOld = trim(exec('stty -g')); - exec('stty raw -echo'); // isig'); + exec('stty raw -echo opost onlret'); // isig'); } public function __destruct() @@ -63,11 +63,9 @@ class LineRead $buffer = $this->buffer; $cursor = strlen($this->prompt) + 1 + $this->posCursor - $this->posScroll; - $promptStyle = $this->styleToAnsi($this->promptStyle); - $commandStyle = $this->styleToAnsi($this->commandStyle); $endStyle = "\e[0m"; - fprintf(STDOUT, "\r\e[2K{$promptStyle}%s{$commandStyle}%s\e[%dG{$endStyle}", $prompt, $buffer, $cursor); + fprintf(STDOUT, "\r\e[2K%s%s\e[%dG{$endStyle}", ($this->promptStyle)($prompt), ($this->commandStyle)($buffer), $cursor); } protected function styleToAnsi($style) @@ -160,12 +158,14 @@ class LineRead $this->commandStyle = $style; } - public function setPrompt($prompt, $style=null) + public function setPromptText($prompt) { $this->prompt = $prompt; - if ($style) { - $this->promptStyle = $style; - } + } + + public function setPromptStyle($style) + { + $this->promptStyle = $style; } } diff --git a/lib/Shell.php b/lib/Shell.php index 3d6ea81..03ab2f4 100644 --- a/lib/Shell.php +++ b/lib/Shell.php @@ -4,58 +4,234 @@ namespace NoccyLabs\Shell; use NoccyLabs\Shell\LineRead; -abstract class Shell +class Shell { - protected $prompt; + const EV_PROMPT = "prompt"; + const EV_COMMAND = "command"; - protected $promptStyle; - - protected $commandStyle; + protected $lineReader = null; - public function __construct(array $config=[]) + protected $context = null; + + protected $contextStack = []; + + protected $listeners = []; + + public function __construct() { - $this->configure($config); + $this->configure(); } - abstract protected function configure(array $config); - - public function addCommand($command, callable $handler=null) + protected function configure() { - if (!$handler) { - if (!($command instanceof Command)) { - throw new \RuntimeException("Handler is not callable nor a Command"); - } - $command->setShell($this); - $this->commands[$command->getName()] = $command; + + } + + /** + * Push a new primary context, saving the previous contexts on a stack. + * + * @param Context $context + */ + public function pushContext(Context $context) + { + if ($this->context) { + array_unshift($this->contextStack, $this->context); + } + $this->context = $context; + $this->dispatchEvent("context.update", [ "context"=>$this->context ]); + } + + /** + * 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->commands[$command] = $handler; + $this->context = null; + } + $this->dispatchEvent("context.update", [ "context"=>$this->context ]); + return $previous; + } + + public function getContextPath($separator=":") + { + $stack = [ $this->context->getName() ]; + foreach ($this->contextStack as $context) { + $stack[] = $context->getName(); + } + $stack = array_reverse($stack); + return join($separator,$stack); + } + + public function createContext() + { + $context = new Context(); + $this->pushContext($context); + return $context; + } + + /** + * Add a handler to be called when an event fires. + * + * @param string $event + * @param callable $handler + */ + public function addListener($event, callable $handler) + { + if (!array_key_exists($event,$this->listeners)) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $handler; + } + + /** + * Invoke event handlers for event. + * + * @param string $event + * @param callable $handler + */ + protected function dispatchEvent($event, array $data=[]) + { + if (!array_key_exists($event,$this->listeners)) { + return; + } + foreach ($this->listeners[$event] as $handler) { + call_user_func($handler, (object)$data, $this); } } - public function execute($command) + /** + * 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=[]) { - if (is_array($command)) { - foreach ($command as $cmd) { - $this->execute($cmd); + + } + + /** + * Remove a created timer. + * + * @param Timer $timer + */ + public function removeTimer(Timer $timer) + { + + } + + public function setPrompt($text) + { + if (!$this->lineReader) { + return; + } + $this->lineReader->setPromptText($text); + } + + /** + * 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 + foreach(array_merge([ $this->context ] , $this->contextStack) as $context) { + if ($context->hasCommand($command)) { + $handler = $context->getCommand($command); + break; } + } + 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; } - $buffer = str_getcsv($command, " ", "\"", "\\"); - - if (count($buffer)>0) { - $this->onCommand($buffer); + // Call the handler if the command was found + if (($target = $this->findCommand($command))) { + $ret = $target(...$args); + if ($ret instanceof Context) { + $this->pushContext($ret); + } + return; } - } - - protected function onCommand($buffer) - { - $this->executeBuffer($buffer); + // Throw error if the command could not be found + throw new Exception\BadCommandException("Command {$command} not found"); } - protected function executeBuffer(array $buffer) + public function executeBuiltin($command, ...$args) { - $commandName = array_shift($buffer); + switch ($command) { + case '.': + printf("context<%s>:\n", $this->context->getName()); + echo " ".join("\n ",explode("\n",json_encode($this->context->getData(),JSON_PRETTY_PRINT)))."\n"; + break; + case '..': + if (count($this->contextStack)>0) + $this->popContext(); + break; + case 'help': + echo + "Built in commands:\n". + " . Show current context\n". + " .. Go to parent context\n". + " exit Exit the shell\n"; + 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"; + } + /* if (array_key_exists($commandName, $this->commands)) { $command = $this->commands[$commandName]; if ($command instanceof Command) { @@ -66,61 +242,52 @@ abstract class Shell return; } $this->writeln("Bad command: ".$commandName); - } - - public function writeln($output) - { - echo "\r\e[K\e[0m".$output."\n"; - } - - protected function onUpdate() - { - } - - public function setPrompt($prompt) - { - $this->prompt = $prompt; - } - - public function setPromptStyle($style) - { - $this->promptStyle = $style; - } - - public function setCommandStyle($style) - { - $this->commandStyle = $style; + */ } public function run() { - $lineRead = new LineRead(); - - $lineRead->setCommandStyle($this->commandStyle); + $this->lineReader = new LineRead(); + + $this->lineReader->setPromptText("shell>"); + $this->lineReader->setPromptStyle(new Style(Style::BR_GREEN)); + $this->lineReader->setCommandStyle(new Style(Style::GREEN)); $this->running = true; - do { - $lineRead->setPrompt($this->prompt, $this->promptStyle); - $buffer = $lineRead->update(); + $this->dispatchEvent("prompt", [ "context"=>$this->context ]); + + while ($this->running) { + // Update the input stuff, sleep if nothing to do. + if (!($buffer = $this->lineReader->update())) { + usleep(10000); + continue; + } + // we get a ^C on ^C, so deal with the ^C. if ($buffer == "\x03") { $this->stop(); continue; } + // Execute the buffer ob_start(); - $this->onUpdate(); - if ($buffer !== null) { - $this->execute($buffer); - } - $buf = ob_get_contents(); + $this->executeBuffer($buffer); + $output = ob_get_contents(); ob_end_clean(); - if ($buf) { - $lineRead->erase(); - echo str_replace("\n", "\r\n", rtrim($buf)."\n"); - $lineRead->redraw(); + + if (trim($output)) { + $this->lineReader->erase(); + echo rtrim($output)."\n"; + $this->lineReader->redraw(); } - usleep(10000); - } while ($this->running); + + if (!$this->context) { + $this->stop(); + } + + $this->dispatchEvent("prompt", [ "context"=>$this->context ]); + } + + $this->lineReader = null; } diff --git a/lib/Style.php b/lib/Style.php new file mode 100644 index 0000000..963e1d4 --- /dev/null +++ b/lib/Style.php @@ -0,0 +1,56 @@ +fg = $fg; + $this->bg = $bg; + } + + public function __invoke($string) + { + $pre = null; $post = null; + if ($this->fg !== self::NONE) { + $pre.= "\e[".(($this->fg > 7)?(90+($this->fg-8)):(30+$this->fg))."m"; + $post.= "\e[39m"; + } + if ($this->bg !== self::NONE) { + $pre.= "\e[".(($this->bg > 7)?(100+($this->bg-8)):(40+$this->bg))."m"; + $post.= "\e[49m"; + } + + return $pre . $string . $post; + + } + +} \ No newline at end of file