Compare commits

...

12 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
13 changed files with 535 additions and 93 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

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

View File

@ -1,12 +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>");
$myShell->pushContext(new Context());
// 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();

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();

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();

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,7 +24,7 @@ class MyShell extends Shell
protected $seq = 0;
protected function configure()
public function __construct()
{
$context = new MyContext();
$this->pushContext($context);

View File

@ -66,6 +66,11 @@ class Context
$this->findCommands();
}
public function onEnter()
{
}
protected function findCommands()
{
$refl = new \ReflectionClass(get_called_class());

View File

@ -2,6 +2,10 @@
namespace NoccyLabs\Shell;
/**
* This is a readline-like implementation in pure PHP.
*
*/
class LineRead
{
@ -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;
}

View File

@ -3,32 +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;
}
/**
@ -44,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();
}
/**
@ -60,7 +130,7 @@ class Shell
} else {
$this->context = null;
}
$this->dispatchEvent("context.update", [ "context"=>$this->context ]);
$this->dispatchEvent(self::EVT_CONTEXT_CHANGED);
return $previous;
}
@ -81,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
@ -165,15 +211,79 @@ class Shell
});
}
/**
* 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.
*
@ -208,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;
}
@ -224,11 +346,31 @@ 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;
}
@ -236,6 +378,13 @@ class Shell
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) {
@ -269,19 +418,30 @@ class Shell
}
}
ksort($ghelp);
printf("Commands in current context:\n");
$_ = 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) {
printf(" %-20s %s\n", $command, $info);
if (strpos($command," ")!==false) {
list($command,$args) = explode(" ",$command,2);
} else $args=null;
$_($command, $args,$info);
}
if (count($ghelp)) {
printf("\nImported from parent contexts:\n");
foreach ($ghelp as $command=>$info) {
printf(" %-20s %s\n", $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("\nGlobal commands:\n");
printf(" %-20s %s\n", "exit", "Leave the shell");
printf(" %-20s %s\n", "..", "Discard the current context and go to parent");
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();
@ -308,32 +468,25 @@ 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()
{
try {
$this->lineReader = new LineRead();
$this->lineReader->setPromptText($this->prompt);
$this->lineReader->setPromptStyle(new Style(Style::BR_GREEN));
$this->lineReader->setCommandStyle(new Style(Style::GREEN));
$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("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.
@ -342,12 +495,12 @@ class Shell
}
// Escape is handy too...
if ($buffer == "\e") {
$this->dispatchEvent("shell.abort");
$this->dispatchEvent(self::EVT_SHELL_ESCAPE);
continue;
}
// we get a ^C on ^C, so deal with the ^C.
if ($buffer == "\x03") {
$this->dispatchEvent("shell.stop");
$this->dispatchEvent(self::EVT_SHELL_ABORT);
$this->stop();
continue;
}
@ -357,6 +510,15 @@ class Shell
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);
}
@ -366,15 +528,17 @@ class Shell
if (trim($output)) {
$this->lineReader->erase();
echo rtrim($output)."\n";
$this->lineReader->redraw();
if ($this->running)
$this->lineReader->redraw();
}
if (!$this->context) {
$this->stop();
break;
}
if ($buffer) {
$this->dispatchEvent("prompt", [ "context"=>$this->context ]);
$this->dispatchEvent(self::EVT_UPDATE_PROMPT);
}
}
@ -382,10 +546,55 @@ class Shell
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();
}