Compare commits

...

20 Commits

Author SHA1 Message Date
Chris 08ab24d665 Bugfixes to help command 2017-01-30 12:10:23 +01:00
Chris fdd1814875 Moved onEnter event 2017-01-29 20:31:04 +01:00
Chris 75c624520d Updated examples, added onEnter method to contexts 2017-01-29 20:27:17 +01:00
Chris ec60970b5d Added getContext method to shell 2017-01-28 13:00:26 +01:00
Chris 7c76928c3b Even more tweaks 2017-01-27 04:43:44 +01:00
Chris ff9845e23e Tweaks to help styling 2017-01-27 04:36:23 +01:00
Chris e5b328b822 Spiced up the help 2017-01-27 04:30:50 +01:00
Chris cee82fc740 Added getInput() method for basic async input 2017-01-25 21:49:26 +01:00
Chris c7b1a637c2 Removed descr from command props 2017-01-24 21:13:20 +01:00
Chris 809c04abfa Code cleanup, better examples, tasks added 2017-01-24 14:42:43 +01:00
Chris 4cd5cc2620 Comments and improvements 2017-01-24 00:49:13 +01:00
Chris 5a45ca9c46 Rewrite, cleanup and bugfixes
* More events added, constants cleaned up
* Events now handled using noccylabs/tinyevent
* Fixed magenta/cyan mixup in style
* Fixed LineRead not resetting history pointer on command
2017-01-23 23:28:12 +01:00
Chris b6727bed80 Fixed timer implementation 2017-01-22 23:22:13 +01:00
Chris 0e5a25567c Added exception handling to run() 2016-11-21 01:26:06 +01:00
Christopher Vagnetoft bdec60717f Improved the cursor in LineRead when using history 2016-11-19 15:46:13 +01:00
Christopher Vagnetoft 43a6475192 Added an execute() method to context to catch unhandled commands 2016-11-19 14:18:53 +01:00
Chris 7bfd8453e7 Improved the context stack 2016-11-15 03:29:00 +01:00
Chris 482d8a54e5 Prompt can now be set before linereader is created 2016-11-12 16:37:51 +01:00
Chris b2a23f992d Fixed an issue with the help command 2016-11-04 02:23:10 +01:00
Chris 455574b6a5 Fixed issue with global commands in parent contexts 2016-11-02 22:49:19 +01:00
17 changed files with 763 additions and 143 deletions

25
CHANGELOG.md Normal file
View File

@ -0,0 +1,25 @@
Changelog and Upgrade Instructions
==================================
## 0.2.x to 0.3.x
Major changes:
* The `Shell::EV_*` constants have been renamed to `Shell::EVT_*`.
* The `configure` method has been removed. If you really need to
extend the `Shell` class, use the constructor to configure your
shell (see *examples/timers.php*)
* Events are now dispatched using the *NoccyLabs/TinyEvent* library.
The main difference here is in how the event object received. The
shell instance is now passed in the object, and you can provide any
userdata to be passed on to the handler when you add the listener.
* The event `Shell::EVT_BAD_COMMAND` will be fired if a command can
not be found, assuming `Context->execute()` does not accept the
command. (see *examples/commandevents.php*)
New features:
* Tasks can now be added and removed from the shell. Tasks will receive
a call to their `update()` method once per main loop, until they are
removed or return false from the `isValid()` method. Tasks need to
implement the `TaskInterface` interface. (see *examples/tasks.php*)

View File

@ -0,0 +1,12 @@
NoccyLabs Shell Core
====================
This library helps make elegant command line applications that spawn an isolated shell.
It uses a standalone implementation for buffered input with support for arrow keys to
navigate the history and more.
Note that this library requirements a fully ANSI compatible terminal with UTF-8 support
in order to use colors, control the cursor position etc. As it uses `stty` to configure
input buffering, it will likely not work on Windows.

View File

@ -13,5 +13,8 @@
"psr-4": {
"NoccyLabs\\Shell\\": "lib/"
}
},
"require": {
"noccylabs/tinyevent": "~0.1.1"
}
}
}

36
examples/basic.php Normal file
View File

@ -0,0 +1,36 @@
<?php
/*
* Basic shell example, demonstrates adding a listener to update the prompt with
* the current time as well as creating an "anonymous" context and adding a
* command to it. It also shows how to change the style of the prompt.
*
*/
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Style;
use NoccyLabs\Shell\Context;
$myShell = new Shell();
// Set the style of prompt and input
$myShell->setPromptStyle(new Style(Style::BR_GREEN, Style::GREEN));
$myShell->setInputStyle(new Style(Style::BR_CYAN));
// Add a listener to update the prompt
$myShell->addListener(Shell::EVT_UPDATE_PROMPT, function ($e) {
$e->shell->setPrompt(date("H:i:s").">");
});
// Set the initial prompt, not really needed.
$myShell->setPrompt("test>");
// Create an anonymous context and add a command
$ctx = $myShell->createContext("root");
$ctx->addCommand("hello", function () {
echo "Hello World!\n";
}, [ 'help'=>'Say hello' ]);
// Run the shell
$myShell->run();

20
examples/catchall.php Normal file
View File

@ -0,0 +1,20 @@
<?php
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Context;
class CatchAllContext extends Context
{
public function execute($cmd, ...$arg)
{
printf("Executing: %s %s\n", $cmd, join(" ",$arg));
return true;
}
}
$myShell = new Shell();
$myShell->setPrompt("test>");
$myShell->pushContext(new CatchAllContext());
$myShell->run();

View File

@ -0,0 +1,44 @@
<?php
/*
* This example demonstrates how to use events to catch commands that
* have not been handled by any context or builtin.
*
*/
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Style;
use NoccyLabs\Shell\Context;
$myShell = new Shell();
// Add a listeners for various command aspects
$myShell->addListener(Shell::EVT_BAD_COMMAND, function ($e) {
echo "EVT_BAD_COMMAND:\n";
printf("| command: %s\n", $e->command);
printf("|_args: %s\n", join(" ",$e->args));
});
$myShell->addListener(Shell::EVT_BEFORE_COMMAND, function ($e) {
echo "EVT_BEFORE_COMMAND:\n";
printf("| command: %s\n", $e->command);
printf("|_args: %s\n", join(" ",$e->args));
});
$myShell->addListener(Shell::EVT_AFTER_COMMAND, function ($e) {
echo "EVT_AFTER_COMMAND:\n";
printf("| command: %s\n", $e->command);
printf("| args: %s\n", join(" ",$e->args));
printf("|_type: %s\n", $e->type);
});
// Set the initial prompt, not really needed.
$myShell->setPrompt("test>");
// Create an anonymous context and add a command
$ctx = $myShell->createContext("root");
$ctx->addCommand("hello", function ($who="World") {
echo "Hello {$who}!\n";
}, [ "help"=>"Say hello", "args"=>"who" ]);
// Run the shell
$myShell->run();

View File

@ -6,21 +6,46 @@ use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Context;
$shell = new Shell();
$shell->addListener("prompt", function ($event, $shell) {
$name = $shell->getContextPath();
$shell->setPrompt("shell{$name}> ");
$shell->addListener(Shell::EVT_UPDATE_PROMPT, function ($event) {
$name = $event->shell->getContextPath();
$event->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);
class MyContext extends Context
{
public function __construct()
{
// Remember to call the parent constructor if you want to use
// the doccomment syntax to mark commands, as demonstrated
// at the end of this class for the bar command.
parent::__construct();
$this->addCommand("foo",[$this,"foo"],[
'help' => "Foo command"
]);
}
public function onEnter()
{
echo "Entering context!\n";
}
public function foo()
{
echo "Foo!\n";
}
/**
* @command bar
* @args
* @help Bar command
*/
public function bar()
{
echo "Bar!\n";
}
}
$shell->pushContext(new MyContext());
$shell->run();

19
examples/errors.php Normal file
View File

@ -0,0 +1,19 @@
<?php
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Context;
class CatchAllContext extends Context
{
public function execute($cmd, ...$arg)
{
throw new \Exception("Uh-oh! Error!");
}
}
$myShell = new Shell();
$myShell->setPrompt("test>");
$myShell->pushContext(new CatchAllContext());
$myShell->run();

31
examples/input.php Normal file
View File

@ -0,0 +1,31 @@
<?php
/*
* This example demonstrates how to use events to catch commands that
* have not been handled by any context or builtin.
*
*/
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Style;
use NoccyLabs\Shell\Context;
$myShell = new Shell();
// Set the initial prompt, not really needed.
$myShell->setPrompt("test>");
// Create an anonymous context and add a command
$ctx = $myShell->createContext("root");
$ctx->addCommand("hello", function () use ($myShell) {
$myShell->getInput("What is your name?", function ($name) use ($myShell) {
echo "Hello, {$name}\n";
$myShell->getInput("Who is your daddy and what does he do?", function ($daddy) {
echo "{$daddy}? Oookay...\n";
});
});
});
// Run the shell
$myShell->run();

View File

@ -20,6 +20,29 @@ class MyContext extends Context
* @command testme
* @args
* @help Useful test!
* @global
*/
public function test()
{
echo "Test\n";
}
/**
* @command context
* @help Create a new context
*/
public function context()
{
return new OtherContext("newcontext");
}
}
class OtherContext extends Context
{
/**
* @command other
* @args
* @help Other test
*/
public function test()
{

60
examples/tasks.php Normal file
View File

@ -0,0 +1,60 @@
<?php
/*
* This example demonstrates how to use events to catch commands that
* have not been handled by any context or builtin.
*
*/
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\Shell\Shell;
use NoccyLabs\Shell\Style;
use NoccyLabs\Shell\Context;
use NoccyLabs\Shell\TaskInterface;
class MyTask implements TaskInterface
{
protected $start;
protected $last;
public function __construct()
{
$this->start = time();
}
public function update()
{
if ($this->last < time()) {
echo date("H:i:s")."\n";
$this->last = time();
}
}
public function isValid()
{
return (time() - $this->start < 5);
}
}
$myShell = new Shell();
$myShell->addListener(Shell::EVT_TASK_CREATED, function ($e) {
echo "EVT_TASK_CREATED:\n";
printf("|_task: %s\n", get_class($e->task));
});
$myShell->addListener(Shell::EVT_TASK_DESTROYED, function ($e) {
echo "EVT_TASK_DESTROYED:\n";
printf("|_task: %s\n", get_class($e->task));
});
$myShell->addTask(new MyTask());
// Set the initial prompt, not really needed.
$myShell->setPrompt("test>");
// Create an anonymous context and add a command
$ctx = $myShell->createContext("root");
$ctx->addCommand("hello", function () {
echo "Hello World!\n";
});
// Run the shell
$myShell->run();

View File

@ -24,15 +24,21 @@ class MyShell extends Shell
protected $seq = 0;
protected function configure()
public function __construct()
{
$context = new MyContext();
$this->pushContext($context);
$this->updatePrompt();
$this->addTimer(5000, function () {
$t1 = $this->addTimer(5000, function () {
echo "5 seconds\n";
});
$app = $this;
$t2 = $this->addTimer(15000, function () use ($t1, $app) {
echo "Removing timers...\n";
$app->removeTimer($t1);
});
}
protected function updatePrompt()

View File

@ -23,6 +23,11 @@ class Context
$this->configure();
}
public function getContextInfo()
{
return null;
}
public function setShell(Shell $shell)
{
$this->shell = $shell;
@ -61,6 +66,11 @@ class Context
$this->findCommands();
}
public function onEnter()
{
}
protected function findCommands()
{
$refl = new \ReflectionClass(get_called_class());
@ -71,7 +81,7 @@ class Context
}, explode("\n", $docblock));
$info = [];
foreach ($lines as $line) {
if (preg_match("/^@(command|help|args) (.+?)$/", $line, $match)) {
if (preg_match("/^@(command|help|args|global)\\s*(.*)$/", $line, $match)) {
list($void,$key,$value) = $match;
$info[$key] = $value;
}
@ -132,6 +142,32 @@ class Context
return $ret;
}
public function isCommandGlobal($command)
{
if (strpos($command," ")!==false) {
list($command, $void) = explode(" ",$command,2);
}
$info = $this->commandInfo[$command];
return array_key_exists('global', $info);
}
/**
* Catch-all handler for commands not defined in context, globally or builtin.
* Override this function and return true if the command is handled ok.
*
* @param string $command The command to execute
* @param string[] $args The arguments to the command
* @return bool True if the command was handled
*/
public function execute($command, ...$args)
{
return false;
}
/**
* Get the name of the context
*
*/
public function getName()
{
return $this->name;

View File

@ -2,6 +2,10 @@
namespace NoccyLabs\Shell;
/**
* This is a readline-like implementation in pure PHP.
*
*/
class LineRead
{
@ -68,11 +72,11 @@ class LineRead
$this->posCursor = strlen($this->buffer);
}
$cursor = strlen($this->prompt) + 1 + $this->posCursor - $this->posScroll;
$cursor = strlen($this->prompt) + 2 + $this->posCursor - $this->posScroll;
$endStyle = "\e[0m";
fprintf(STDOUT, "\r\e[2K%s%s\e[%dG{$endStyle}", ($this->promptStyle)($prompt), ($this->commandStyle)($buffer), $cursor);
fprintf(STDOUT, "\r\e[2K%s %s\e[%dG{$endStyle}", ($this->promptStyle)($prompt), ($this->commandStyle)($buffer), $cursor);
}
protected function styleToAnsi($style)
@ -127,6 +131,7 @@ class LineRead
array_unshift($this->history, $this->buffer);
$this->buffer = null;
$this->posCursor = 0;
$this->posHistory = 0;
printf("\n\r");
$this->state = self::STATE_IDLE;
}
@ -161,22 +166,33 @@ class LineRead
if ($this->posHistory == 0) {
$this->stashedBuffer = $this->buffer;
}
if ($this->posCursor == strlen($this->buffer)) {
$this->posCursor = -1;
}
if ($this->posHistory < count($this->history)) {
$this->posHistory++;
$this->buffer = $this->history[$this->posHistory-1];
$this->redraw();
}
if ($this->posCursor == -1) {
$this->posCursor = strlen($this->buffer);
}
$this->redraw();
break;
case "\e[B": // down
if ($this->posCursor == strlen($this->buffer)) {
$this->posCursor = -1;
}
if ($this->posHistory > 1) {
$this->posHistory--;
$this->buffer = $this->history[$this->posHistory-1];
$this->redraw();
} elseif ($this->posHistory > 0) {
$this->posHistory--;
$this->buffer = $this->stashedBuffer;
$this->redraw();
}
if ($this->posCursor == -1) {
$this->posCursor = strlen($this->buffer);
}
$this->redraw();
break;
default:
fprintf(STDERR, "\n%s\n", substr($code,1));

View File

@ -3,30 +3,101 @@
namespace NoccyLabs\Shell;
use NoccyLabs\Shell\LineRead;
use NoccyLabs\TinyEvent\EventEmitterTrait;
use NoccyLabs\TinyEvent\Event;
class Shell
{
const EV_PROMPT = "prompt";
const EV_COMMAND = "command";
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_BAD_COMMAND = "shell.command.bad"; // 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
const EVT_TASK_CREATED = "task.created"; // a task was created
const EVT_TASK_DESTROYED = "task.destryed"; // a task was removed or invalidated
/**
* @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 = [];
protected $listeners = [];
/**
* @var object[] Created timers
*/
protected $timers = [];
/**
* @var TaskInterface[] Created tasks
*/
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;
/**
* @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()
{
$this->configure();
$t = $this;
register_shutdown_function(function () use (&$t) {
if ($t) unset($t);
});
}
protected function configure()
/**
* 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;
}
/**
@ -42,7 +113,8 @@ class Shell
}
$context->setShell($this);
$this->context = $context;
$this->dispatchEvent("context.update", [ "context"=>$this->context ]);
$this->dispatchEvent(self::EVT_CONTEXT_CHANGED);
$context->onEnter();
}
/**
@ -58,7 +130,7 @@ class Shell
} else {
$this->context = null;
}
$this->dispatchEvent("context.update", [ "context"=>$this->context ]);
$this->dispatchEvent(self::EVT_CONTEXT_CHANGED);
return $previous;
}
@ -79,43 +151,19 @@ class Shell
return join($separator,$stack);
}
public function createContext()
/**
* 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();
$context = new Context($name);
$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);
}
}
/**
* 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
@ -135,6 +183,7 @@ class Shell
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;
}
@ -155,17 +204,84 @@ class Shell
*
* @param Timer $timer
*/
public function removeTimer(Timer $timer)
public function removeTimer($timer)
{
$this->timers = array_filter($this->timers, function ($v) use ($timer) {
return ($v !== $timer);
});
}
public function setPrompt($text)
/**
* 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->lineReader) {
if ($this->dispatchEvent(self::EVT_TASK_CREATED, [
'task' => $task
])->isPropagationStopped()) {
return;
}
$this->lineReader->setPromptText($text);
$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);
}
}
/**
@ -176,12 +292,18 @@ class Shell
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 ($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;
}
@ -196,13 +318,25 @@ class Shell
* Execute a command with arguments.
*
* @param string $command The command name to execute
* @param string $args Arguments
* @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;
}
@ -212,22 +346,55 @@ class Shell
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(), json_encode($this->context->getData()));
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>\n", str_repeat(" ",$level++), $type, $context->getName());
printf(" %s└─%s<%s>: %s\n", str_repeat(" ",$level++), $type, $context->getName(), $context->getContextInfo());
}
break;
case '..':
@ -236,13 +403,45 @@ class Shell
break;
case 'help':
$help = $this->context->getCommandHelp();
printf("Commands in current context:\n\n");
foreach ($help as $command=>$info) {
printf(" %-20s %s\n", $command, $info);
$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;
}
}
}
printf("\nGlobal commands:\n\n");
printf(" %-20s %s\n", "exit", "Leave the shell");
printf(" %-20s %s\n", "..", "Discard the current context and go to parent");
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)) {
foreach ($ghelp as $command=>$info) {
printf("\e[1mCommands from parent contexts:\e[0m\n");
if (strpos($command," ")!==false) {
list($command,$args) = explode(" ",$command,2);
} else $args=null;
if (!array_key_exists($command,$ghelp)) continue;
$_($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();
@ -269,79 +468,133 @@ class Shell
} 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) {
$command->run($buffer);
} else {
call_user_func_array($command,$buffer);
}
return;
}
$this->writeln("Bad command: ".$commandName);
*/
}
/**
* Start the shell
*
*/
public function run()
{
$this->lineReader = new LineRead();
try {
$this->lineReader = new LineRead();
$this->lineReader->setPromptText("shell>");
$this->lineReader->setPromptStyle(new Style(Style::BR_GREEN));
$this->lineReader->setCommandStyle(new Style(Style::GREEN));
$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->running = true;
$this->dispatchEvent("prompt", [ "context"=>$this->context ]);
$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("shell.abort");
continue;
}
// we get a ^C on ^C, so deal with the ^C.
if ($buffer == "\x03") {
$this->dispatchEvent("shell.stop");
$this->stop();
continue;
}
// Execute the buffer
ob_start();
$this->dispatchEvent("update");
foreach ($this->timers as $timer) {
$timer->update();
}
if ($buffer) {
$this->executeBuffer($buffer);
}
$output = ob_get_contents();
ob_end_clean();
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";
$this->lineReader->redraw();
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);
}
}
if (!$this->context) {
$this->stop();
}
if ($buffer) {
$this->dispatchEvent("prompt", [ "context"=>$this->context ]);
}
} 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;

View File

@ -11,16 +11,17 @@ class Style
const GREEN = 2;
const YELLOW = 3;
const BLUE = 4;
const CYAN = 5;
const MAGENTA = 6;
const MAGENTA = 5;
const CYAN = 6;
const WHITE = 7;
const GRAY = 8;
const BR_RED = 9;
const BR_GREEN = 10;
const BR_YELLOW = 11;
const BR_BLUE = 12;
const BR_CYAN = 13;
const BR_MAGENTA = 14;
const BR_MAGENTA = 13;
const BR_CYAN = 14;
const BR_WHITE = 15;
const A_INTENSE = 1;

10
lib/TaskInterface.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace NoccyLabs\Shell;
interface TaskInterface
{
public function update();
public function isValid();
}