diff --git a/Vagrantfile b/Vagrantfile index 0afa153..60a2a54 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -6,7 +6,6 @@ Vagrant.configure("2") do |config| # Increase memory size as HHVM requires at minimum 1GB config.vm.provider "virtualbox" do |v| - v.name = "titon" v.memory = 2048 v.cpus = 2 v.customize ["modifyvm", :id, "--ostype", "Ubuntu_64"] diff --git a/composer.json b/composer.json index 790eaae..ae1bc9b 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "src/Titon/Annotation/bootstrap.hh", "src/Titon/Cache/bootstrap.hh", "src/Titon/Common/bootstrap.hh", + "src/Titon/Console/bootstrap.hh", "src/Titon/Context/bootstrap.hh", "src/Titon/Controller/bootstrap.hh", "src/Titon/Crypto/bootstrap.hh", diff --git a/composer.lock b/composer.lock index b43b255..5894349 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "c0f1a81f242cea33502e8809011e436d", + "hash": "aefafd0ec2ffe9a5e58f18d8323f3906", "packages": [ { "name": "psr/http-message", diff --git a/docs/en/index.md b/docs/en/index.md index a4c1bc5..5e8304b 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -7,11 +7,12 @@ Packages * [Annotation](packages/annotation/index.md) * [Cache](packages/cache/index.md) * [Common](packages/common/index.md) +* [Console](packages/console/index.md) * [Context](packages/context/index.md) * [Controller](packages/controller/index.md) * [Crypto](packages/crypto/index.md) * [Debug](packages/debug/index.md) -* [Environment](packages/environment/index.md) +* [Environment](packages/env/index.md) * [Event](packages/event/index.md) * [HTTP](packages/http/index.md) * [Intl](packages/intl/index.md) diff --git a/docs/en/packages/console/application.md b/docs/en/packages/console/application.md new file mode 100644 index 0000000..cd68d0a --- /dev/null +++ b/docs/en/packages/console/application.md @@ -0,0 +1,94 @@ +# Application # + +There are two distinct parts to a console application, the first being `Titon\Console\Application`, which is the application itself. The application handles the [reading of input](input.md), [sending of output](output.md), and the [registering and running of commands](commands.md). + +```hack +$app = new Titon\Console\Application(); +``` + +An application can be customized with a name, version, and banner, all of which will be displayed when the application is ran. The banner is usually a piece of ASCII art using heredoc. + +```hack +$app->setName('Titon Framework'); +$app->setVersion('1.0.0'); +$app->setBanner($banner); +``` + +The second part to a console application is the [kernel](../kernel/index.md), represented with `Titon\Console\Console`. The kernel provides a pipeline for executing middleware in the context of the command line, as well as running the application mentioned previously. The `Application` must be passed as the 1st argument. + +```hack +$console = new Titon\Console\Console($app); +``` + +## Running & Exiting ## + +Once all necessary [commands](commands.md) have been added, calling `run()` on the `Console` object will execute the application. The `run()` method requires a `Titon\Console\Input` and `Titon\Console\Output` object as arguments for handling user input from the client and formatting output. + +```hack +$console->run(new Titon\Console\Input(), new Titon\Console\Output()); +``` + +Once the application has ran, a proper exit code needs to be sent -- 0 if successful or 1 otherwise. This can be solved using the `terminate()` method. + +```hack +$console->terminate(); +``` + +### Execution Flow ### + +When executing the application from the command line, the `Application` object will handle the following scenarios: + +* If no command is provided or the `--help` flag is present, the help screen of the application will be displayed listing all global flags, global options, and available commands. +* If a command is present and the `--help` flag is present, the help screen of the given command will be displayed listing all available options, flags, and arguments as well as the usage of the command and its description. +* If the command is present with valid parameters, the application will execute the `run()` method on the command. + +## Parameters ## + +There are 4 types of parameters, a command, arguments, flags, and options. A command must be the first parameter passed and only 1 command can be used, however, nested commands are supported. For example, if our script was named "titon.hh", and our command was named "cmd", we can do the following: + +``` +hhvm titon.hh cmd +``` + +An argument is a value passed to the command -- think method or function arguments. Multiple arguments can be passed and accessed in the order they were defined. + +``` +hhvm titon.hh cmd arg1 arg2 arg3 +``` + +A flag is a boolean value that starts with a double dash (`--`), or a single dash (`-`) for its shorthand variation. The default boolean value can be reversed/negated by prepending `no-` to the flag name. + +``` +hhvm titon.hh cmd --flag --no-flag -f +``` + +And finally the option, which also starts with a double dash (`--`) and allows arbitrary values to be defined by name. There are 4 variations of options, the first with the value separated by a space, the second using an equals sign, and the last 2 wrapping the value in quotes (when special characters or spaces are used). + +``` +hhvm titon.hh cmd --opt1 foo --opt2=bar --opt3 "baz baz" --opt4="qux qux" +``` + +### Global Parameters ### + +The console application will automatically bootstrap and define the following global parameters. + +* `--help` (`-h`) - The help flag will display the help screen of the application or given command. +* `--quiet` (`-q`) - The quiet flag will set the verbosity of the application to 0, suppressing all output. +* `--verbose` (`-v`) The verbose flag will set the verbosity level of the application. This flag is stackable, so each instance of `v` will increase the verbosity level by 1. +* `--ansi` - The ansi flag will force ANSI output. +* `--no-ansi` - The no-ansi flag will suppress all ANSI output. + +## Shebang ## + +For easier use on the command line, the application file can include a shebang and drop the file extension. For example, if we have a file simply called "titon" instead of "titon.hh", we could do the following. + +``` +#!/usr/bin/env hhvm +addCommand(new MyCommand()); +``` diff --git a/docs/en/packages/console/old/commands.md b/docs/en/packages/console/old/commands.md new file mode 100644 index 0000000..b26ba5b --- /dev/null +++ b/docs/en/packages/console/old/commands.md @@ -0,0 +1,37 @@ +# Commands # + +A `Command` is a user-created class that handles a specific process with executed by the user from a `Console` application. + +## Setup ## + +A `Command` object is required to have at least 2 methods: `configure` and `run`. + +The `configure` method is where the user will specify the name of the command, an optional description, and register any command-specific parameters to be used at runtime. + +The `run` method is executed by the `Console` after all necessary input is processed. + +```hack +class HelloWorldCommand { + + public function configure() { + $this->setName("hello")->setDescription("A simple 'hello world' command!"); + $this->addArgument(new Argument("name")); + } + + public function run() { + if (!is_null($name = $this->getArgument('name'))) { + $this->out("Hello, $name!"); + } else { + $this->out("Hello, world!"); + } + } +} +``` + +## Accessing Parameters ## + +As shown above, the `getArgument` method inside of the command returns a value as opposed to the `Input` class which returns the `Argument` object. The methods `getFlag`, `getOption`, and `getArgument`, when used inside of the `Command`, will return the value (or default) of the registered parameter. If there is a necessity to access the actual objects, these can be retrieved by calling the same commands on the class's `Input` variable. + +```hack +$nameArgument = $this->input->getArgument('name'); +``` diff --git a/docs/en/packages/console/old/input.md b/docs/en/packages/console/old/input.md new file mode 100644 index 0000000..688fc20 --- /dev/null +++ b/docs/en/packages/console/old/input.md @@ -0,0 +1,145 @@ +# Input # + +All user input is handled by the `Input` class. This is where you define all input that is accepted in the command and/or application itself. The `Input` class also handles parsing of the input to be easily read later. + +## Parameters ## + +Parameters are any input given by the user into the command line application. The supported types of parameters are flags, options, arguments, and commands. + +#### Description #### + +The (optional) description of each parameter can also be set either in its constructor or via a `setter` method. This description is used when rendering the help screen. + +```hack +$param->setDescription('This is a handy parameter!'); +``` + +#### Aliases #### + +Flags and options also support aliasing. Given the flag `--foo`, an alias assigned to it of `-f` will allow the user to retrieve the flag and assign values using either name. + +```hack +$param = (new Flag('foo'))->alias('f'); +``` + +#### Mode #### + +The `mode` determines if user input is required for the paramter. If no input is given and the mode is set to be required, then an exception will be thrown. + +The `mode` is set at construction or through a `setter` method. By default, all parameters are set to optional. + +```hack +$param->setMode(AbstractInputDefinition::MODE_REQUIRED); +``` + +### Flags ### + +A `Flag` is a boolean flag that is only checked by the parser for its existence. If the flag is present, it is given a value of 1 and a value of 0 is given if it is not. + +**NOTE:** Although we say its a `boolean` parameter, that only means the parser does not attempt to give the parameter a value read from the user input. It only checks for the parameter's existence. The `value` assigned to flags are actually numeric to allow for stackable flags. + +A flag is added to the `Input` object via the `addFlag` method. + +```hack +$input->addFlag(new Flag('foo')); +``` + +Construction of a `Flag` is done by denoting a name for the flag, an optional description, a `mode` (if the flag is required or not), an a boolean value to determine if the flag is stackable. + +```hack +$flag = new Flag('verbose', 'Set the verbosity level of the application', Flag::MODE_OPTIONAL, true); +``` + +A registered `Flag` object can be retrieved from the `Input` class by calling the `getFlag` method and passing in either the name or alias of the flag. + +```hack +$myFlag = $input->getFlag('foo'); // Valid +$myFlag = $input->getFlag('f'); // Also valid +``` + +All registered flags can also be retrieved via the `getFlags` method. + +#### Stackable Flags ##### + +If a flag is stackable, then the presence of multiple instances of the flag (consecutively) will increase the value of the flag. + +```bash +-vvv // Given a value of 3 +``` + +If the flag is not stackable, then it is either given a value of 1 (present) and 0 (if not present). + +### Options ### + +An `Option` is a value-based parameter that is assigned a value based on the input from the command line. The parser handles many different types of notations for reading in options: + +```bash +// Long notation +--foo bar +--foo=bar +--foo="bar" + +// Short notation +-f bar +-f=bar +-f="bar" +``` + +An option is added to the `Input` object via the `addOption` method. + +```hack +$input->addOption(new Option('foo')); +``` + +**NOTE:** If a value containing spaces is to meant to be assigned to an option, it is required to be in quotes. + +A registered `Option` object can be retrieved from the `Input` class by calling the `getOption` method and passing in either the name or alias of the option. + +```hack +$myOption = $input->getOption('foo'); // Valid +$myOption = $input->getOption('f'); // Also valid +``` + +All registered options can also be retrieved via the `getOptions` method. + +### Arguments ### + +Arguments are parameters passed into the application that are not represented by any specific notation. For example, a 'hello world' application may accept a name argument and would be run as: + +```hack +hhvm hello.hh "Alex Phillips" +``` + +An argument is added to the `Input` object via the `addArgument` method. + +```hack +$input->addArgument(new Argument('foo')); +``` + +A registered `Argument` object can be retrieved from the `Input` class by calling the `getArgument` method and passing in the name of the argument. + +```hack +$nameArgument = $input->getArgument('name'); +``` + +All registered arguments can also be retrieved via the `getArguments` method. + +## [Commands](commands.md) ## + +Commands are added to the application through the `addCommand` method in the `Input` class. You can either preemptively parse a command out from the `Input` by calling `getActiveCommand`, or simply call `parse` which will parse out the command along with the rest of the registered parameters. + +Once the input has been parsed, `getActiveCommand` will return whatever command has been parsed. + +```hack +$input->addCommand(new MyCommand()); +``` + +*See [Commands](commands.md) for more information.* + +## User Input ## + +The `Input` class also manages all user input via STDIN. This functionality is used in the [user input](user-input.md) classes for retrieving information from the user. If this functionality is needed outside of these classes, simply call `getUserInput` to prompt and return the entered value. + +```hack +$usersValue = $input->getUserInput(); +``` diff --git a/docs/en/packages/console/output.md b/docs/en/packages/console/output.md new file mode 100644 index 0000000..8b98409 --- /dev/null +++ b/docs/en/packages/console/output.md @@ -0,0 +1,86 @@ +# Output # + +Sending output is handled by the `Titon\Console\Output` class. Normal output is sent to stdout through the `php://stdout` stream, while errors are sent to stderr through the `php://stderr` stream. + +```hack +$output = new Titon\Console\Output(); +``` + +To send a normal message, the `out()` method can be used. This method requires a message string, an optional newline count to append (defaults to 1), and an optional verbosity level. + +```hack +$output->out('This is a normal message', 2); // 2 newlines +``` + +Alternatively, the `error()` method can be used to write error messages. + +```hack +$output->error('Oh no, this is an error'); +``` + +## Verbosity ## + +Every message sent has an assigned verbosity level to it (defaults to 1). The `Output` class has an assigned verbosity level which determines whether to send the output or not. If the message verbosity is equal to or below the `Output` verbosity, the message will be sent. + +To set the verbosity level, simply pass an integer to the `Output` constructor (defaults to 1), or use the `setVerbosity()` method. + +```hack +$output->setVerbosity(3); +``` + +
+ The verbosity level is automatically set based on the --verbose and -v flags. +
+ +## Colors ## + +Colored output requires [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) to be supported in the client terminal. The Console package will automatically detect ANSI support and output accordingly, however, ANSI can be toggled through command line flags or `Output` methods. + +The `--ansi` flag or the `setForceAnsi()` method will force ANSI coloring. + +```hack +$output->setForceAnsi(true); +``` + +The `--no-ansi` flag or the `setSuppressAnsi()` method will suppress ANSI coloring. + +```hack +$output->setSuppressAnsi(true); +``` + +## Style Definitions ## + +Style definitions are a simple way to format messages for coloring using a familiar XML/HTML syntax. When sending a message, simply wrap the portion of the message string to be colored in a specific tag, for example: + +```hack +$output->out('This is an informational message'); +``` + +The following default style definitions are supported. + +* `` - Green text, default background. +* `` - Yellow text, default background. +* `` - Red text, default background. +* `` - White text, red background. + +### Custom Styles ### + +The `setStyle()` method can be used for defining custom styles. This method requires a unique name and a `Titon\Console\StyleDefinition` instance. The `StyleDefinition` requires a foreground color, an optional background color, and a list of optional effects. + +```hack +$output->setStyle('question', new Titon\Console\StyleDefinition('white', 'purple', Vector {})); +``` + +The `` tag is now usable when outputting a message. + +```hack +$output->out('Is this correct?'); +``` + +## Exceptions ## + +When an uncaught exception is thrown from within the console application, the exception message will be output using the `` tag. To manually output an exception, use the `renderException()` method. + +```hack +$output->renderException($exception); +``` diff --git a/docs/en/packages/index.md b/docs/en/packages/index.md index 1b37053..024ef87 100644 --- a/docs/en/packages/index.md +++ b/docs/en/packages/index.md @@ -5,11 +5,12 @@ The Titon Framework aims to be extremely modular by encapsulating similar functi * [Annotation](annotation/index.md) - Class and method metadata annotating * [Cache](cache/index.md) - Data caching through storage engines * [Common](common/index.md) - Common interfaces, traits, and abstract implementations +* [Console](console/index.md) - Command line applications * [Context](context/index.md) - Dependency containers and service providers * [Controller](controller/index.md) - Controllers and actions in the dispatch cycle * [Crypto](crypto/index.md) - Secure data encryption and decryption * [Debug](debug/index.md) - Debugging, logging, and benchmarking -* [Environment](environment/index.md) - Environment detection and host bootstrapping +* [Environment](env/index.md) - Environment detection and host bootstrapping * [Event](event/index.md) - Observers, listeners, and event emitting * [HTTP](http/index.md) - Request and response handling * [Intl](intl/index.md) - Internationalization and localization diff --git a/src/Titon/Console/Application.hh b/src/Titon/Console/Application.hh new file mode 100644 index 0000000..7811eab --- /dev/null +++ b/src/Titon/Console/Application.hh @@ -0,0 +1,338 @@ + + */ + protected CommandList $commands = Vector {}; + + /** + * The `Input` object used to retrieve parsed parameters and commands. + * + * @var \Titon\Console\Input + */ + protected Input $input; + + /** + * The name of the application. + * + * @var string + */ + protected string $name = ''; + + /** + * The `Output` object used to send response data to the user. + * + * @var \Titon\Console\Output + */ + protected Output $output; + + /** + * The version of the application. + * + * @var string + */ + protected string $version = ''; + + /** + * Construct a new `Application` instance. + * + * @param \Titon\Console\Input|null $input The `Input` object to inject + * @param \Titon\Console\Output|null $input The `Output` object to inject + */ + public function __construct(?Input $input = null, ?Output $output = null) { + if (is_null($input)) { + $input = new Input(); + } + + if (is_null($output)) { + $output = new Output(); + } + + $this->input = $input; + $this->output = $output; + } + + /** + * Add a `Command` to the application to be parsed by the `Input`. + * + * @param \Titon\Console\Command $command The `Command` object to add + * + * @return $this + */ + public function addCommand(Command $command): this { + $this->commands[] = $command; + + return $this; + } + + /** + * Bootstrap the `Application` instance with default parameters and global + * settings. + */ + protected function bootstrap(): void { + foreach ($this->commands as $command) { + $command->setInput($this->input); + $command->setOutput($this->output); + $this->input->addCommand($command); + } + + /* + * Add global flags + */ + $this->input->addFlag((new Flag('help', 'Display this help screen.')) + ->alias('h')); + $this->input->addFlag((new Flag('quiet', 'Suppress all output.')) + ->alias('q')); + $this->input->addFlag((new Flag('verbose', 'Set the verbosity of the application\'s output.')) + ->alias('v') + ->setStackable(true)); + $this->input->addFlag((new Flag('version', "Display the application's version")) + ->alias('V')); + $this->input->addFlag(new Flag('ansi', "Force ANSI output")); + $this->input->addFlag(new Flag('no-ansi', "Disable ANSI output")); + + /* + * Add default styles + */ + $this->output->setStyle('info', new StyleDefinition('green')); + $this->output->setStyle('warning', new StyleDefinition('yellow')); + $this->output->setStyle('error', new StyleDefinition('red')); + $this->output->setStyle('exception', new StyleDefinition('white', 'red')); + } + + /** + * Retrieve the application's banner. + * + * @return string + */ + public function getBanner(): string { + return $this->banner; + } + + /** + * Retrieve the console's `Input` object. + * + * @return \Titon\Console\Input + */ + public function getInput(): Input { + return $this->input; + } + + /** + * Retrieve the application's name. + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Retrieve the console's `Output` object. + * + * @return \Titon\Console\Output + */ + public function getOutput(): Output { + return $this->output; + } + + /** + * Retrieve the application's version. + * + * @return string + */ + public function getVersion(): string { + return $this->version; + } + + /** + * Run the application. + * + * @param \Titon\Console\Input|null $input The `Input` object to inject + * @param \Titon\Console\Output|null $input The `Output` object to inject + */ + public function run(?Input $input = null, ?Output $output = null): int { + if (!is_null($input)) { + $this->input = $input; + } + if (!is_null($output)) { + $this->output = $output; + } + + try { + $this->bootstrap(); + $this->command = $this->input->getActiveCommand(); + + if (is_null($this->command)) { + $this->input->parse(); + if ($this->input->getFlag('version')->getValue() === 1) { + $this->renderVersionInformation(); + } else { + $this->renderHelpScreen(); + } + } else { + $this->runCommand($this->command); + } + } catch (Exception $e) { + $this->output->renderException($e); + + return $this->shutdown(1); + } + + return $this->shutdown(0); + } + + /** + * Register and run the `Command` object. + * + * @param Command $command The `Command` to run + */ + public function runCommand(Command $command): void { + $command->registerInput(); + $this->input->parse(); + + if ($this->input->getFlag('help')->getValue() === 1) { + $this->renderHelpScreen($command); + return; + } + + if ($this->input->getFlag('version')->getValue() === 1) { + $this->renderVersionInformation(); + return; + } + + if ($this->input->getFlag('ansi')->getValue() === 1) { + $this->output->setForceAnsi(true); + + } else if ($this->input->getFlag('no-ansi')->getValue() === 1) { + $this->output->setSuppressAnsi(true); + } + + $flag = $this->input->getFlag('quiet'); + + $verbositySet = false; + + if ($flag->exists()) { + $verbositySet = true; + $this->output->setVerbosity(0); + } + + if ($verbositySet === false) { + $flag = $this->input->getFlag('verbose'); + $verbosity = $flag->getValue(1); + + invariant(!is_null($verbosity), "Must not be null."); + + $this->output->setVerbosity($verbosity); + } + + $command->run(); + } + + /** + * Render the help screen for the application or the `Command` passed in. + * + * @param \Titon\Console\Command|null $command The `Command` to render usage for + */ + public function renderHelpScreen(?Command $command = null): void { + $helpScreen = new HelpScreen($this); + if (!is_null($command)) { + $helpScreen->setCommand($command); + } + + $this->output->out($helpScreen->render()); + } + + /** + * Output version information of the current `Application`. + */ + public function renderVersionInformation(): void { + if ($this->getVersion()) { + $this->output->out("{$this->getName()} v{$this->getVersion()}"); + } + } + + /** + * Set the banner of the application. + * + * @param string $banner The banner decorator + * + * @return $this + */ + public function setBanner(string $banner): this { + $this->banner = $banner; + + return $this; + } + + /** + * Set the name of the application. + * + * @param string $name The name of the application + * + * @return $this + */ + public function setName(string $name): this { + $this->name = $name; + + return $this; + } + + /** + * Set the version of the application. + * + * @param string $version The version of the application + * + * @return $this + */ + public function setVersion(string $version): this { + $this->version = $version; + + return $this; + } + + /** + * Termination method executed at the end of the application's run. + */ + protected function shutdown(int $exitCode): int { + return $exitCode; + } +} diff --git a/src/Titon/Console/Bag/ArgumentBag.hh b/src/Titon/Console/Bag/ArgumentBag.hh new file mode 100644 index 0000000..abf2ebd --- /dev/null +++ b/src/Titon/Console/Bag/ArgumentBag.hh @@ -0,0 +1,20 @@ + { + +} diff --git a/src/Titon/Console/Bag/FlagBag.hh b/src/Titon/Console/Bag/FlagBag.hh new file mode 100644 index 0000000..1481fba --- /dev/null +++ b/src/Titon/Console/Bag/FlagBag.hh @@ -0,0 +1,20 @@ + { + +} diff --git a/src/Titon/Console/Bag/OptionBag.hh b/src/Titon/Console/Bag/OptionBag.hh new file mode 100644 index 0000000..870dd1d --- /dev/null +++ b/src/Titon/Console/Bag/OptionBag.hh @@ -0,0 +1,20 @@ + { + +} diff --git a/src/Titon/Console/Command.hh b/src/Titon/Console/Command.hh new file mode 100644 index 0000000..4650a90 --- /dev/null +++ b/src/Titon/Console/Command.hh @@ -0,0 +1,104 @@ +arguments = new ArgumentBag(); + $this->flags = new FlagBag(); + $this->options = new OptionBag(); + } + + /** + * Add a new `Argument` to be registered and parsed with the `Input`. + * + * @param \Titon\Console\InputDefinition\Argument $argument + * + * @return $this + */ + public function addArgument(Argument $argument): this { + $this->arguments->set($argument->getName(), $argument); + + return $this; + } + + /** + * Add a new `Flag` to be registered and parsed with the `Input`. + * + * @param \Titon\Console\InputDefinition\Flag $flag + * + * @return $this + */ + public function addFlag(Flag $flag): this { + $this->flags->set($flag->getName(), $flag); + + return $this; + } + + /** + * Add a new `Option` to be registered and parsed with the `Input`. + * + * @param \Titon\Console\InputDefinition\Option $option + * + * @return $this + */ + public function addOption(Option $option): this { + $this->options->set($option->getName(), $option); + + return $this; + } + + /** + * Construct and return a new `Menu` object given the choices and display + * message. + * + * @param Map $choices Accepted values + * @param string $message The message to display before the choices + * + * @return \Titon\Console\UserInput\Confirm + */ + protected function confirm(string $default = ''): Confirm { + $confirm = new Confirm($this->input, $this->output); + $confirm->setDefault($default); + + return $confirm; + } + + /** + * Alias method for sending output through STDERROR. + * + * @param string $output The message to send + */ + protected function error(string $output): void { + $this->output->error($output); + } + + /** + * Retrieve an `Argument` value by key. + * + * @param string $key + * + * @return mixed + */ + protected function getArgument(string $key, ?string $default = null): ?string { + return $this->input->getArgument($key)->getValue($default); + } + + /** + * Retrieve all `Argument` objects registered specifically to this command. + * + * @return \Titon\Console\Bag\ArgumentBag + */ + public function getArguments(): ArgumentBag { + return $this->arguments; + } + + /** + * Retrieve the command's description. + * + * @return string + */ + public function getDescription(): string { + return $this->description; + } + + /** + * Retrieve a `Flag` value by key. + * + * @param string $key + * + * @return mixed + */ + protected function getFlag(string $key, ?int $default = null): ?int { + return $this->input->getFlag($key)->getValue($default); + } + + /** + * Retrieve all `Flag` objects registered specifically to this command. + * + * @return \Titon\Console\Bag\FlagBag + */ + public function getFlags(): FlagBag { + return $this->flags; + } + + /** + * Retrieve the command's name. + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Retrieve an `Option` value by key. + * + * @param string $key + * + * @return mixed + */ + protected function getOption(string $key, ?string $default = null): ?string { + return $this->input->getOption($key)->getValue($default); + } + + /** + * Retrieve all `Option` objects registered specifically to this command. + * + * @return \Titon\Console\Bag\OptionBag + */ + public function getOptions(): OptionBag { + return $this->options; + } + + /** + * Construct and return a new `Menu` object given the choices and display + * message. + * + * @param Map $choices Accepted values + * @param string $message The message to display before the choices + * + * @return \Titon\Console\UserInput\Menu + */ + protected function menu(Map $choices, string $message = ''): Menu { + $menu = new Menu($this->input, $this->output); + $menu->setAcceptedValues($choices)->setMessage($message); + + return $menu; + } + + /** + * Alias method for sending output through STDOUT. + * + * @param string $output The message to send + */ + protected function out(string $output): void { + $this->output->out($output); + } + + /** + * Construct and return a new instance of `ProgressBarFeedback`. + * + * @param int $total + * @param string $message + * @param int $interval + * + * @return \Titon\Console\Feedback\ProgressBarFeedback + */ + protected function progressBar(int $total = 0, string $message = '', int $interval = 100): ProgressBarFeedback { + return new ProgressBarFeedback($this->output, $total, $message, $interval); + } + + /** + * Construct and return a new `Prompt` object given the accepted choices and + * default value. + * + * @param Map $choices Accepted values + * @param string $default Default value + * + * @return \Titon\Console\UserInput\Prompt + */ + protected function prompt(Map $choices = Map {}, string $default = ''): Prompt { + $prompt = new Prompt($this->input, $this->output); + $prompt->setAcceptedValues($choices)->setDefault($default); + + return $prompt; + } + + /** + * {@inheritdoc} + */ + public function registerInput(): this { + $arguments = (new ArgumentBag())->add($this->arguments->all()); + foreach ($this->input->getArguments() as $name => $argument) { + $arguments[$name] = $argument; + } + $this->input->setArguments($arguments); + + $flags = (new FlagBag())->add($this->flags->all()); + foreach ($this->input->getFlags() as $name => $flag) { + $flags->set($name, $flag); + } + $this->input->setFlags($flags); + + $options = (new OptionBag())->add($this->options->all()); + foreach ($this->input->getOptions() as $name => $option) { + $options->set($name, $option); + } + $this->input->setOptions($options); + + return $this; + } + + /** + * Set the command's description. + * + * @param string $description The description for the command + * + * @return $this + */ + public function setDescription(string $description): this { + $this->description = $description; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setInput(Input $input): this { + $this->input = $input; + + return $this; + } + + /** + * Set the command's name. + * + * @param string $name The name of the command + * + * @return $this + */ + public function setName(string $name): this { + $this->name = $name; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setOutput(Output $output): this { + $this->output = $output; + + return $this; + } + + /** + * Construct and return a new `WaitFeedback` object. + * + * @param int $total The total number of cycles of the process + * @param string $message The message presented with the feedback + * @param int $interval The time interval the feedback should update + */ + protected function wait(int $total = 0, string $message = '', int $interval = 100): WaitFeedback { + return new WaitFeedback($this->output, $total, $message, $interval); + } +} diff --git a/src/Titon/Console/Console.hh b/src/Titon/Console/Console.hh new file mode 100644 index 0000000..30672de --- /dev/null +++ b/src/Titon/Console/Console.hh @@ -0,0 +1,29 @@ + { + + /** + * {@inheritdoc} + */ + public function handle(Input $input, Output $output, Next $next): Output { + $this->exitCode = $this->getApplication()->run($input, $output); + + return $output; + } + +} diff --git a/src/Titon/Console/Exception/InvalidCharacterSequence.hh b/src/Titon/Console/Exception/InvalidCharacterSequence.hh new file mode 100644 index 0000000..19db566 --- /dev/null +++ b/src/Titon/Console/Exception/InvalidCharacterSequence.hh @@ -0,0 +1,20 @@ + $characterSequence = Vector {}; + + /** + * The current cycle out of the given total. + * + * @var int + */ + protected int $current = 0; + + /** + * The format the feedback indicator will be displayed as. + * + * @var string + */ + protected string $format = '{prefix}{feedback}{suffix}'; + + /** + * The current iteration of the feedback used to calculate the speed. + * + * @var int + */ + protected int $iteration = 0; + + /** + * The interval (in miliseconds) between updates of the indicator. + * + * @var int + */ + protected int $interval = 100; + + /** + * The max length of the characters in the character sequence. + * + * @var int + */ + protected int $maxLength = 1; + + /** + * The message to be displayed with the feedback. + * + * @var string + */ + protected string $message; + + /** + * The `Output` used for displaying the feedback information. + * + * @var \Titon\Console\Output + */ + protected Output $output; + + /** + * The template used to prefix the output. + * + * @var string + */ + protected string $prefix = '{message} {percent}% ['; + + /** + * The current speed of the feedback. + * + * @var float + */ + protected float $speed = 0.0; + + /** + * The time the feedback started. + * + * @var int + */ + protected int $start = -1; + + /** + * The template used to suffix the output. + * + * @var string + */ + protected string $suffix = '] {elapsed} / {estimated}'; + + /** + * The current tick used to calculate the speed. + * + * @var int + */ + protected int $tick = -1; + + /** + * The feedback running time. + * + * @var int + */ + protected int $timer = -1; + + /** + * The total number of cycles expected for the feedback to take until finished. + * + * @var int + */ + protected int $total = 0; + + /** + * Create a new instance of the `Feedback`. + * + * @param int $total The total number of cycles + * @param string $message The message to be displayed with the feedback + * @param int $interval The interval the feedback should update in + */ + public function __construct(Output $output, int $total = 0, string $message = '', int $interval = 100) { + $this->output = $output; + $this->message = $message; + $this->interval = $interval; + $this->setTotal($total); + } + + /** + * {@inheritdoc} + */ + public function advance(int $increment = 1): void { + $this->current = min($this->total, $this->current + $increment); + + if ($this->shouldUpdate()) { + $this->display(); + } + + if ($this->current === $this->total) { + $this->display(true); + } + } + + /** + * Build and return all variables that are accepted when building the prefix + * and suffix for the output. + * + * @return Map + */ + protected function buildOutputVariables(): Map { + $message = $this->message; + $percent = str_pad(floor($this->getPercentageComplete() * 100), 3);; + $estimated = $this->formatTime((int)$this->estimateTimeRemaining()); + $elapsed = str_pad( + $this->formatTime($this->getElapsedTime()), strlen($estimated) + ); + + $variables = Map { + 'message' => $message, + 'percent' => $percent, + 'elapsed' => $elapsed, + 'estimated' => $estimated, + }; + + return $variables; + } + + /** + * Method used to format and output the display of the feedback. + * + * @param bool $finish If this is the finishing display of the feedback + */ + abstract protected function display(bool $finish = false): void; + + /** + * Given the speed and currently elapsed time, calculate the estimated time + * remaining. + * + * @return float + */ + protected function estimateTimeRemaining(): float { + $speed = $this->getSpeed(); + if (is_null($speed) || !$this->getElapsedTime()) { + return 0.0; + } + + return round($this->total / $speed); + } + + /** + * {@inheritdoc} + */ + public function finish(): void { + $this->current = $this->total; + $this->display(true); + $this->output->out(); + } + + /** + * Format the given time for output. + * + * @param int $time The timestamp to format + */ + protected function formatTime(int $time): string { + return floor($time / 60) . ':' . str_pad( + $time % 60, 2, 0, STR_PAD_LEFT + ); + } + + /** + * Retrieve the current elapsed time. + * + * @var int + */ + protected function getElapsedTime(): int { + if ($this->start < 0) { + return 0; + } + + return (time() - $this->start); + } + + /** + * Retrieve the percentage complete based on the current cycle and the total + * number of cycles. + * + * @return float + */ + protected function getPercentageComplete(): float { + if ($this->total == 0) { + return 1.0; + } + + return (float) ($this->current / $this->total); + } + + /** + * Get the current speed of the feedback. + * + * @return float + */ + protected function getSpeed(): float { + if ($this->start < 0) { + return 0.0; + } + + if ($this->tick < 0) { + $this->tick = $this->start; + } + + $now = microtime(true); + $span = $now - $this->tick; + + if ($span > 1) { + $this->iteration++; + $this->tick = $now; + $this->speed = (float)(($this->current / $this->iteration) / $span); + } + + return $this->speed; + } + + /** + * Retrieve the total number of cycles the feedback should take. + * + * @return int + */ + protected function getTotal(): int { + return number_format($this->total); + } + + /** + * Set the characters used in the output. + * + * @param Vector $characters The characters to use + * + * @return $this + */ + public function setCharacterSequence(Vector $characters): this { + $this->characterSequence = $characters; + $this->setMaxLength(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setInterval(int $interval): this { + $this->interval = $interval; + + return $this; + } + + /** + * Set the maximum length of the available character sequence characters. + * + * @return $this + */ + protected function setMaxLength(): this { + $this->maxLength = max( + $this->characterSequence->map(($key) ==> strlen($key))->toArray() + ); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setMessage(string $message): this { + $this->message = $message; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setPrefix(string $prefix): this { + $this->prefix = $prefix; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setSuffix(string $sufix): this { + $this->suffix = $sufix; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setTotal(int $total): this { + $this->total = (int)$total; + + return $this; + } + + /** + * Determine if the feedback should update its output based on the current + * time, start time, and set interval. + * + * @return bool + */ + protected function shouldUpdate(): bool { + $now = microtime(true) * 1000; + + if ($this->timer < 0) { + $this->start = (int)(($this->timer = $now) / 1000); + + return true; + } + + if (($now - $this->timer) > $this->interval) { + $this->timer = $now; + + return true; + } + + return false; + } + +} diff --git a/src/Titon/Console/Feedback/ProgressBarFeedback.hh b/src/Titon/Console/Feedback/ProgressBarFeedback.hh new file mode 100644 index 0000000..3ecc767 --- /dev/null +++ b/src/Titon/Console/Feedback/ProgressBarFeedback.hh @@ -0,0 +1,83 @@ + $characterSequence = Vector { + '=', + '>' + }; + + /** + * {@inheritdoc} + */ + protected function display(bool $finish = false): void { + $completed = $this->getPercentageComplete(); + $variables = $this->buildOutputVariables(); + + // Need to make prefix and suffix before the bar so we know how long to render it. + $prefix = Str::insert($this->prefix, $variables); + $suffix = Str::insert($this->suffix, $variables); + + $size = SystemFactory::factory()->getWidth(); + $size -= strlen($prefix . $suffix); + if ($size < 0) { + $size = 0; + } + + // substr is needed to trim off the bar cap at 100% + $bar = str_repeat($this->characterSequence[0], floor($completed * $size)) . $this->characterSequence[1]; + $bar = substr(str_pad($bar, $size, ' '), 0, $size); + + $variables = Map { + 'prefix' => $prefix, + 'feedback' => $bar, + 'suffix' => $suffix + }; + + if (!$this->output->isAnsiAllowed()) { + return; + } + + $eol = ($finish === true) ? Output::LF : Output::CR; + + $this->output->out(Str::insert($this->format, $variables, Map {'escape' => false}), Output::VERBOSITY_NORMAL, 1, $eol); + } + + /** + * {@inheritdoc} + */ + public function setCharacterSequence(Vector $characters): this { + if ($characters->count() !== 2) { + throw new InvalidCharacterSequence("Display bar must only contain 2 values"); + } + + parent::setCharacterSequence($characters); + + return $this; + } + +} diff --git a/src/Titon/Console/Feedback/WaitFeedback.hh b/src/Titon/Console/Feedback/WaitFeedback.hh new file mode 100644 index 0000000..fb311ee --- /dev/null +++ b/src/Titon/Console/Feedback/WaitFeedback.hh @@ -0,0 +1,85 @@ + $characterSequence = Vector { + '-', + '\\', + '|', + '/' + }; + + /** + * {@inheritdoc} + */ + protected string $prefix = "{message}"; + + /** + * {@inheritdoc} + */ + protected string $suffix = ""; + + /** + * {@inheritdoc} + */ + public function __construct(Output $output, int $total = 0, string $message = '', int $interval = 100) { + parent::__construct($output, $total, $message, $interval); + $this->iteration = 0; + } + + /** + * {@inheritdoc} + */ + public function display(bool $finish = false): void { + $variables = $this->buildOutputVariables(); + + $index = $this->iteration++ % $this->characterSequence->count(); + $feedback = str_pad($this->characterSequence[$index], $this->maxLength + 1); + + $prefix = Str::insert($this->prefix, $variables); + $suffix = Str::insert($this->suffix, $variables); + + $variables = Map { + 'prefix' => $prefix, + 'feedback' => $feedback, + 'suffix' => $suffix + }; + + if (!$this->output->isAnsiAllowed()) { + return; + } + + $eol = Output::CR; + if ($finish === true) { + $eol = Output::LF; + } + + $this->output->out( + str_pad(Str::insert( + $this->format, $variables + ), SystemFactory::factory()->getWidth()), + 1, + Output::VERBOSITY_NORMAL, + $eol + ); + } +} diff --git a/src/Titon/Console/HelpScreen.hh b/src/Titon/Console/HelpScreen.hh new file mode 100644 index 0000000..19434d7 --- /dev/null +++ b/src/Titon/Console/HelpScreen.hh @@ -0,0 +1,425 @@ +app = $app; + + $input = $app->getInput(); + $this->commands = $input->getCommands(); + $this->arguments = $input->getArguments(); + $this->flags = $input->getFlags(); + $this->options = $input->getOptions(); + } + + /** + * Build and return the markup for the `HelpScreen`. + * + * @return string + */ + public function render(): string { + $retval = Vector {}; + + if ($heading = $this->renderHeading()) { + $retval[] = $this->renderHeading(); + } + $retval[] = $this->renderUsage(); + + if (!$this->arguments->all()->isEmpty()) { + if ($output = $this->renderSection($this->arguments)) { + $retval[] = "Arguments\n$output"; + } + } + if (!$this->flags->all()->isEmpty()) { + if ($output = $this->renderSection($this->flags)) { + $retval[] = "Flags\n$output"; + } + } + if (!$this->options->all()->isEmpty()) { + if ($output = $this->renderSection($this->options)) { + $retval[] = "Options\n$output"; + } + } + if (is_null($this->command)) { + if (!$this->commands->isEmpty()) { + $retval[] = $this->renderCommands(); + } + } + + return join($retval, "\n\n"); + } + + /** + * Build the list of available `Command` objects that can be called and their + * descriptions. + * + * @return string + */ + protected function renderCommands(): string { + ksort($this->commands); + + $indentation = 0; + $maxLength = max( + $this->commands->keys()->map( + ($key) ==> { + $indentation = substr_count($key, ':'); + $key = str_repeat(' ', $indentation) . $key; + + return strlen($key); + } + ) + ); + $descriptionLength = 80 - 4 - $maxLength; + + $output = Vector {}; + $nestedNames = Vector {}; + foreach ($this->commands as $name => $command) { + $nested = explode(':', $name); + array_pop($nested); + + if (count($nested) > 0) { + $nest = ''; + foreach ($nested as $piece) { + $nest = $nest ? ":$piece" : $piece; + + if ($nestedNames->linearSearch($nest) < 0) { + // If we get here, then we need to list the name, but it isn't + // actually a command, so subtract substr_count by 1. + $nestedNames[] = $nest; + + $indentation = substr_count($name, ':'); + $output[] = str_repeat(' ', $indentation) . str_pad($nest, $maxLength); + } + } + } else { + $nestedNames[] = $name; + } + + $indentation = substr_count($name, ':'); + $formatted = str_repeat(' ', $indentation) . str_pad($name, $maxLength - (2 * $indentation)); + + $description = explode('{{BREAK}}', wordwrap($command->getDescription(), $descriptionLength, "{{BREAK}}")); + $formatted .= ' ' . array_shift($description); + + $pad = str_repeat(' ', $maxLength + 4); + while ($desc = array_shift($description)) { + $formatted .= "\n$pad$desc"; + } + + $formatted = " $formatted"; + + array_push($output, $formatted); + } + + return "Available Commands:\n" . join($output, "\n"); + } + + /** + * Build and return the markup for the heading of the `HelpScreen`. This is + * either the name of the application (when not rendering for a specific + * `Command`) or the name and description of the `Command`. + * + * @return string + */ + protected function renderHeading(): string { + $retval = Vector {}; + + if (!is_null($command = $this->command)) { + + invariant(!is_null($command), "Must be a command."); + + if ($description = $command->getDescription()) { + $retval[] = $command->getName() . ' - ' . $description; + } else { + $retval[] = $command->getName(); + } + } else if ($this->app->getName() !== '') { + if (($banner = $this->app->getBanner()) !== '') { + $retval[] = $banner; + } + + $name = $this->app->getName(); + if (($version = $this->app->getVersion()) !== '') { + $name .= " v$version"; + } + + $retval[] = $name; + } + + return implode("\n", $retval); + } + + /** + * Build and return a specific section of available `Input` objects the user + * may specify. + * + * @param \Titon\Console\InputBag $arguments The parameters to build information for + * + * @return string + */ + protected function renderSection(InputBag $arguments): string { + $entries = Map {}; + foreach ($arguments as $argument) { + $name = $argument->getFormattedName($argument->getName()); + if ($argument->getAlias()) { + $name .= " ({$argument->getFormattedName($argument->getAlias())})"; + } + $entries[$name] = $argument->getDescription(); + } + + $maxLength = max(array_map(function(string $key): int { + return strlen($key); + }, $entries->keys())); + $descriptionLength = 80 - 4 - $maxLength; + + $output = Vector {}; + foreach ($entries as $name => $description) { + $formatted = ' ' . str_pad($name, $maxLength); + $description = explode('{{BREAK}}', wordwrap($description, $descriptionLength, "{{BREAK}}")); + $formatted .= ' ' . array_shift($description); + + $pad = str_repeat(' ', $maxLength + 4); + while ($desc = array_shift($description)) { + $formatted .= "\n$pad$desc"; + } + + $formatted = "$formatted"; + + array_push($output, $formatted); + } + + return join($output, "\n"); + } + + /** + * When rendering a for a `Command`, this method builds and returns the usage. + * + * @return string + */ + protected function renderUsage(): string { + $usage = Vector {}; + if (!is_null($this->command)) { + $command = $this->command; + + // Setting local variable and `invariant`ing it to quiet type checker + invariant(!is_null($command), "Must be a Command."); + + $usage[] = $command->getName(); + + foreach ($command->getFlags() as $argument) { + $arg = $argument->getFormattedName($argument->getName()); + if ($argument->getAlias() !== '') { + $arg .= "|" . $argument->getFormattedName($argument->getAlias()); + } + + if ($argument->getMode() === InputDefinition::MODE_OPTIONAL) { + $usage[] = "[$arg]"; + } + } + foreach ($command->getOptions() as $argument) { + $arg = $argument->getFormattedName($argument->getName()); + if ($argument->getAlias() !== '') { + $arg .= "|" . $argument->getFormattedName($argument->getAlias()); + } + + if ($argument->getMode() === InputDefinition::MODE_OPTIONAL) { + $usage[] = "[$arg]"; + } + } + foreach ($command->getArguments() as $argument) { + $arg = $argument->getName(); + if ($argument->getAlias()) { + $arg = "$arg|{$argument->getAlias()}"; + } + + $arg = "$arg=\"...\""; + if ($argument->getMode() === InputDefinition::MODE_OPTIONAL) { + $usage[] = "[$arg]"; + } + } + } else { + $usage[] = "command"; + if (!$this->flags->all()->isEmpty()) { + $usage[] = "[flags]"; + } + if (!$this->options->all()->isEmpty()) { + $usage[] = "[options]"; + } + } + + return "Usage\n " . join(" ", $usage); + } + + /** + * Set the `Argument` objects to render information for. + * + * @param \Titon\Console\Bag\ArgumentBag $arguments The `Argument` objects avaiable + * + * @return $this + */ + public function setArguments(ArgumentBag $arguments): this { + $this->arguments = $arguments; + + return $this; + } + + /** + * Set the `Command` to render a the help screen for. + * + * @param \Titon\Console\Command $command The `Command` object + * + * @return $this + */ + public function setCommand(Command $command): this { + $this->command = $command; + + return $this; + } + + /** + * Set the `Command` objects to render information for. + * + * @param \Titon\Console\CommandMap $arguments The `Command` objects avaiable + * + * @return $this + */ + public function setCommands(CommandMap $commands): this { + $this->commands = $commands; + + return $this; + } + + /** + * Set the `Flag` objects to render information for. + * + * @param \Titon\Console\Bag\FlagBag $arguments The `Flag` objects avaiable + * + * @return $this + */ + public function setFlags(FlagBag $flags): this { + $this->flags = $flags; + + return $this; + } + + /** + * Set the `Input` the help screen should read all avaiable parameters and + * commands from. + * + * @param \Titon\Console\Input $input The `Input` object with all available + * parameters and commands + * + * @return $this + */ + public function setInput(Input $input): this { + $this->commands = $input->getCommands(); + $this->arguments = $input->getArguments(); + $this->flags = $input->getFlags(); + $this->options = $input->getOptions(); + + return $this; + } + + /** + * Set the name of the application + * + * @param string $name The name (and other information) of the console application + * + * @return $this + */ + public function setName(string $name): this { + $this->name = $name; + + return $this; + } + + /** + * Set the `Option` objects to render information for. + * + * @param \Titon\Console\Bag\OptionBag $arguments The `Option` objects avaiable + * + * @return $this + */ + public function setOptions(OptionBag $options): this { + $this->options = $options; + + return $this; + } +} diff --git a/src/Titon/Console/Input.hh b/src/Titon/Console/Input.hh new file mode 100644 index 0000000..1749cb9 --- /dev/null +++ b/src/Titon/Console/Input.hh @@ -0,0 +1,594 @@ + + */ + protected Vector $invalid = Vector {}; + + /** + * Bag container holding all registered `Option` objects + * + * @var \Titon\Console\Bag\OptionBag + */ + protected OptionBag $options; + + /** + * Boolean if the provided input has already been parsed or not. + * + * @var bool + */ + protected bool $parsed = false; + + /** + * Raw input used at creation of the `Input` object. + * + * @var Vector + */ + protected Vector $rawInput; + + /** + * Stream handle for user input. + * + * @var resource + */ + protected resource $stdin; + + /** + * The 'strict' value of the `Input` object. If set to `true`, then any invalid + * parameters found in the input will throw an exception. + * + * @var bool + */ + protected bool $strict = false; + + /** + * Construct a new instance of Input + * + * @param Vector|null $args + */ + public function __construct(?Vector $args = null, bool $strict = false) { + if (is_null($args)) { + $args = new Vector(array_slice(Server::get('argv'), 1)); + } + + $this->rawInput = $args; + $this->input = new InputLexer($args); + $this->flags = new FlagBag(); + $this->options = new OptionBag(); + $this->arguments = new ArgumentBag(); + $this->strict = $strict; + $this->stdin = fopen(Input::STREAM_STDIN, 'r'); + } + + /** + * Add a new `Argument` candidate to be parsed from input. + * + * @param \Titon\Console\InputDefinition\Argument $argument The `Argument` to add + * + * @return $this + */ + public function addArgument(Argument $argument): this { + $this->arguments->set($argument->getName(), $argument); + + return $this; + } + + /** + * Add a new `Command` candidate to be parsed from input. + * + * @param \Titon\Console\Command $command The `Command` to add + * + * @return $this + */ + public function addCommand(Command $command): this { + $command->configure(); + + $this->commands[$command->getName()] = $command; + + return $this; + } + + /** + * Add a new `Flag` candidate to be parsed from input. + * + * @param \Titon\Console\InputDefinition\Flag $flag The `Flag` to add + * + * @return $this + */ + public function addFlag(Flag $flag): this { + $this->flags->set($flag->getName(), $flag); + + return $this; + } + + /** + * Add a new `Option` candidate to be parsed from input. + * + * @param \Titon\Console\InputDefinition\Option $option The `Option` to add + * + * @return $this + */ + public function addOption(Option $option): this { + $this->options->set($option->getName(), $option); + + return $this; + } + + /** + * Parse and retrieve the active command from the raw input. + * + * @return \Titon\Console\Command|null + */ + public function getActiveCommand(): ?Command { + if ($this->parsed === true) { + return $this->command; + } + + if (!is_null($this->command)) { + return $this->command; + } + + $input = new Vector($this->rawInput); + + foreach ($input as $index => $value) { + if (!is_null($command = $this->commands->get($value))) { + $input->removeKey($index); + + $this->setInput($input); + $this->command = $command; + + return $this->command; + } + } + + return null; + } + + /** + * Retrieve an `Argument` by its key or alias. Returns null if none exists. + * + * @param string $key The key or alias of the `Argument` + * + * @return \Titon\Console\InputDefinition\Argument + */ + public function getArgument(string $key): Argument { + if (is_null($argument = $this->arguments->get($key))) { + throw new InvalidInputDefinitionException(sprintf("The argument %s doesn't exist.", $key)); + } + + invariant($argument instanceof Argument, "Must be an `Argument`."); + + return $argument; + } + + /** + * Retrieve all `Argument` candidates. + * + * @return \Titon\Console\Bag\ArgumentBag + */ + public function getArguments(): ArgumentBag { + return $this->arguments; + } + + /** + * Retrieve a `Command` candidate by its name. + * + * @param string $key The name of the `Command` + * + * @return \Titon\Console\Command|null + */ + public function getCommand(string $name): ?Command { + return $this->commands->get($name); + } + + /** + * Retrieve all `Command` candidates. + * + * @return \Titon\Console\CommandMap + */ + public function getCommands(): CommandMap { + return $this->commands; + } + + /** + * Retrieve a `Flag` by its key or alias. Returns null if none exists. + * + * @param string $key The key or alias of the `Flag` + * + * @return \Titon\Console\InputDefinition\Flag + */ + public function getFlag(string $key): Flag { + if (is_null($flag = $this->flags->get($key))) { + throw new InvalidInputDefinitionException(sprintf("The flag %s doesn't exist.", $key)); + } + + invariant($flag instanceof Flag, "Must be a `Flag`."); + + return $flag; + } + + /** + * Retrieve all `Flag` candidates. + * + * @return \Titon\Console\Bag\FlagBag + */ + public function getFlags(): FlagBag { + return $this->flags; + } + + /** + * Create and return the singleton instance. + * + * @return \Titon\Console\Input + */ + public static function getInstance(): Input { + if (is_null(self::$instance)) { + self::$instance = new Input(); + } + + return self::$instance; + } + + /** + * Retrieve an `Option` by its key or alias. Returns null if none exists. + * + * @param string $key The key or alias of the `Option` + * + * @return \Titon\Console\InputDefinition\Option + */ + public function getOption(string $key): Option { + if (is_null($option = $this->options->get($key))) { + throw new InvalidInputDefinitionException(sprintf("The option %s doesn't exist.", $key)); + } + + invariant($option instanceof Option, "Must be an `Option`."); + + return $option; + } + + /** + * Retrieve all `Option` candidates. + * + * @return \Titon\Console\Bag\OptionBag + */ + public function getOptions(): OptionBag { + return $this->options; + } + + /** + * Return whether the `Input` is running in `strict` mode or not. + * + * @return bool + */ + public function getStrict(): bool { + return $this->strict; + } + + /** + * Read in and return input from the user. + * + * @return string + */ + public function getUserInput(): string { + return trim(fgets($this->stdin)); + } + + /** + * Parse input for all `Flag`, `Option`, and `Argument` candidates. + * + * @return void + */ + public function parse(): void { + foreach ($this->input as $val) { + if ($this->parseFlag($val)) { + continue; + } + if ($this->parseOption($val)) { + continue; + } + + if (is_null($this->command)) { + // If we haven't parsed a command yet, see if we have a match. + if (!is_null($command = $this->commands->get($val['value']))) { + $this->command = $command; + continue; + } + } else if (!is_null($this->commands->get($val['value']))) { + if ($this->strict === true) { + throw new InvalidNumberOfCommandsException("Multiple commands are not supported."); + } + } + + if ($this->parseArgument($val)) { + continue; + } + + if ($this->strict === true) { + throw new InvalidNumberOfArgumentsException(sprintf("No parameter registered for value %s", $val['value'])); + } + + $this->invalid[] = $val; + } + + if (is_null($this->command) && $this->strict === true) { + throw new InvalidNumberOfCommandsException("No command was parsed from the input."); + } + + foreach ($this->flags as $name => $flag) { + if ($flag->getMode() !== InputDefinition::MODE_REQUIRED) { + continue; + } + + if (is_null($flag->getValue())) { + throw new MissingValueException(sprintf("Required flag `%s` is not present.", $flag->getName())); + } + } + + foreach ($this->options as $name => $option) { + if ($option->getMode() !== InputDefinition::MODE_REQUIRED) { + continue; + } + + if (is_null($option->getValue())) { + throw new MissingValueException(sprintf("No value present for required option %s", $option->getName())); + } + } + + foreach ($this->arguments as $name => $argument) { + if ($argument->getMode() !== InputDefinition::MODE_REQUIRED) { + continue; + } + + if (is_null($argument->getValue())) { + throw new MissingValueException(sprintf("No value present for required argument %s", $argument->getName())); + } + } + + $this->parsed = true; + } + + /** + * Determine if a RawInput matches an `Argument` candidate. If so, save its + * value. + * + * @param \Titon\Console\RawInput $key + * + * @return bool + */ + protected function parseArgument(RawInput $input): bool { + foreach ($this->arguments as $argument) { + if (is_null($argument->getValue())) { + $argument->setValue($input['raw']); + $argument->setExists(true); + + return true; + } + } + + return false; + } + + /** + * Determine if a RawInput matches a `Flag` candidate. If so, save its + * value. + * + * @param \Titon\Console\RawInput $key + * + * @return bool + */ + protected function parseFlag(RawInput $input): bool { + $key = $input['value']; + if (!is_null($flag = $this->flags->get($key))) { + invariant($flag instanceof Flag, "Must be a Flag."); + + if ($flag->isStackable()) { + $flag->increaseValue(); + } else { + $flag->setValue(1); + } + + $flag->setExists(true); + + return true; + } + + foreach ($this->flags as $name => $flag) { + invariant($flag instanceof Flag, "Must be a Flag."); + + if ($key === $flag->getNegativeAlias()) { + $flag->setValue(0); + $flag->setExists(true); + + return true; + } + } + + return false; + } + + /** + * Determine if a RawInput matches an `Option` candidate. If so, save its + * value. + * + * @param \Titon\Console\RawInput $key + * + * @return bool + */ + protected function parseOption(RawInput $input): bool { + $key = $input['value']; + $option = $this->options->get($key); + if (is_null($option)) { + return false; + } + + // Peak ahead to make sure we get a value. + $nextValue = $this->input->peek(); + if (is_null($nextValue)) { + throw new MissingValueException(sprintf("No value given for the option %s.", $input['value'])); + } + + if (!$this->input->end() && $this->input->isArgument($nextValue['raw'])) { + throw new MissingValueException("No value is present for option $key"); + } + + $this->input->shift(); + $value = $this->input->current(); + + $matches = []; + if (preg_match('#\A"(.+)"$#', $value['raw'], $matches)) { + $value = $matches[1]; + } else if (preg_match("#\A'(.+)'$#", $value['raw'], $matches)) { + $value = $matches[1]; + } else { + $value = $value['raw']; + } + + $option->setValue($value); + $option->setExists(true); + + return true; + } + + /** + * Set the arguments of the `Input`. This will override all existing arguments. + * + * @param \Titon\Console\Bag\ArgumentBag $options The arguments to set + * + * @return $this + */ + public function setArguments(ArgumentBag $arguments): this { + $this->arguments = $arguments; + + return $this; + } + + /** + * Set the flags of the `Input`. This will override all existing flags. + * + * @param \Titon\Console\Bag\FlagBag $options The flags to set + * + * @return $this + */ + public function setFlags(FlagBag $flags): this { + $this->flags = $flags; + + return $this; + } + + /** + * Set the input to be parsed. + * + * @param Vector $args The input to be parsed + * + * @return $this + */ + public function setInput(Vector $args): this { + $this->rawInput = $args; + $this->input = new InputLexer($args); + $this->parsed = false; + $this->command = null; + + return $this; + } + + /** + * Set the options of the `Input`. This will override all existing options. + * + * @param \Titon\Console\Bag\OptionBag $options The options to set + * + * @return $this + */ + public function setOptions(OptionBag $options): this { + $this->options = $options; + + return $this; + } + + /** + * Set the strict value of the `Input` + * + * @param bool $strict Boolean if the `Input` should run strictly or not + * + * @return $this + */ + public function setStrict(bool $strict): this { + $this->strict = $strict; + + return $this; + } +} diff --git a/src/Titon/Console/InputBag.hh b/src/Titon/Console/InputBag.hh new file mode 100644 index 0000000..1a46696 --- /dev/null +++ b/src/Titon/Console/InputBag.hh @@ -0,0 +1,40 @@ + extends AbstractBag { + + /** + * Retrieve the `InputDefinition` object based on the given key. The key is + * checked against all available names as well as aliases. + * + * @param string $key The key to match + * @param T $default The default value to return if no `InputDefinition` + * is found + * + * @return T|null + */ + public function get(string $key, ?T $default = null): ?T { + if (is_null($arg = parent::get($key))) { + foreach ($this as $val) { + if ($key === $val->getAlias()) { + return $val; + } + } + } + + return $default; + } +} diff --git a/src/Titon/Console/InputDefinition.hh b/src/Titon/Console/InputDefinition.hh new file mode 100644 index 0000000..ea6f6b8 --- /dev/null +++ b/src/Titon/Console/InputDefinition.hh @@ -0,0 +1,79 @@ + implements InputDefinition { + + /** + * An alternate notation to specify the input as. + * + * @var string + */ + protected string $alias = ''; + + /** + * The description of the input. + * + * @var string + */ + protected string $description; + + /** + * Flag if the `InputDefinition` has been assigned a value. + * + * @var bool + */ + protected bool $exists = false; + + /** + * The mode of the input to determine if it should be required by the user. + * + * @var int + */ + protected int $mode = self::MODE_OPTIONAL; + + /** + * The name and primary method to specify the input. + * + * @var string + */ + protected string $name; + + /** + * The value the user has given the input. + * + * @var T|null + */ + protected ?T $value; + + /** + * Cosntruct a new instance of an `InputDefinition`. + * + * @param string $name The name of the parameter + * @param string $description The description used in displaying usage + * @param int $mode The mode of the definition + */ + public function __construct(string $name, string $description = '', int $mode = self::MODE_OPTIONAL) { + $this->name = $name; + $this->description = $description; + $this->mode = $mode; + } + + /** + * Alias method for setting the alias of the `InputDefinition`. + * + * @param string $alias The input's alias + * + * @return $this + */ + public function alias(string $alias): this { + return $this->setAlias($alias); + } + + /** + * {@inheritdoc} + */ + public function exists(): bool { + return $this->exists; + } + + /** + * Retrieve the alias of the `InputDefinition`. + * + * @return string + */ + public function getAlias(): string { + return $this->alias; + } + + /** + * Retrieve the description of the `InputDefinition`. + * + * @return string + */ + public function getDescription(): string { + return $this->description; + } + + /** + * Retrieve the formatted name as it should be entered by the user. + * + * @param string $name The string name to format + * + * @return string + */ + public function getFormattedName(string $name): string { + if (strlen($name) === 1) { + return "-$name"; + } + + return "--$name"; + } + + /** + * Retrieve the mode of the `InputDefinition`. + * + * @return int + */ + public function getMode(): int { + return $this->mode; + } + + /** + * Retrieve the name of the `InputDefinition`. + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Retrieve the value as specified by the user for the `InputDefinition`. If + * the user has not specified the value, the default value is returned. + * + * @return T|null + */ + public function getValue(?T $default = null): ?T { + if (!is_null($this->value)) { + return $this->value; + } + + return $default; + } + + /** + * Set the alias of the `InputDefinition`. + * + * @param string $alias The alternate name to specify + * + * @return string + */ + public function setAlias(string $alias): this { + if (strlen($alias) > strlen($this->name)) { + $this->alias = $this->name; + $this->name = $alias; + } else { + $this->alias = $alias; + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setExists(bool $exists): this { + $this->exists = $exists; + + return $this; + } + + /** + * Set the value of the `InputDefinition`. + * + * @param int $value The value given to the `InputDefinition` + * + * @return $this + */ + public function setValue(T $value): this { + $this->value = $value; + + return $this; + } +} \ No newline at end of file diff --git a/src/Titon/Console/InputDefinition/Argument.hh b/src/Titon/Console/InputDefinition/Argument.hh new file mode 100644 index 0000000..daed912 --- /dev/null +++ b/src/Titon/Console/InputDefinition/Argument.hh @@ -0,0 +1,25 @@ + { + + /** + * {@inheritdoc} + */ + <<__Override>> + public function getFormattedName(string $name): string { + return $name; + } +} \ No newline at end of file diff --git a/src/Titon/Console/InputDefinition/Flag.hh b/src/Titon/Console/InputDefinition/Flag.hh new file mode 100644 index 0000000..a7972b8 --- /dev/null +++ b/src/Titon/Console/InputDefinition/Flag.hh @@ -0,0 +1,119 @@ + { + + /** + * The negative alias of the `Flag` (i.e., --no-foo for -foo). A negative + * value is only available if a 'long' `Flag` name is available. + * + * @var string + */ + protected string $negativeAlias = ''; + + /** + * Whether the flag is stackable or not (i.e., -fff is given a value of 3). + * + * @var bool + */ + protected bool $stackable = false; + + /** + * Construct a new `Flag` object + * + * @param string $name The name the flag should be specified with + * @param string $description The description of the flag + * @param int $mode The mode of the flag + * @param bool $stackable Whether the flag is stackable or not + */ + public function __construct(string $name, string $description = '', int $mode = self::MODE_OPTIONAL, bool $stackable = false) { + parent::__construct($name, $description, $mode); + + if (strlen($name) > 1) { + $this->negativeAlias = "no-$name"; + } + + $this->stackable = $stackable; + } + + /** + * Retrieve the negative alias of the `Flag` or null of none. + * + * @return string + */ + public function getNegativeAlias(): string { + return $this->negativeAlias; + } + + /** + * If the `Flag` is stackable, increase its value for each occurrence of the + * flag. + * + * @return $this + */ + public function increaseValue(): this { + if ($this->stackable) { + if (is_null($this->value)) { + $this->value = 1; + } else { + invariant(is_int($this->value), 'Must be an integer.'); + + $this->value++; + } + } + + return $this; + } + + /** + * Retrieve whether the `Flag` is stackable or not. + * + * @return bool + */ + public function isStackable(): bool { + return $this->stackable; + } + + /** + * Set an alias for the `Flag`. If the 'name' given at construction is a short + * name and the alias set is long, the 'alias' given here will serve as the + * 'name' and the original name will be set to the 'alias'. + * + * @param string $alias The alias for the `Flag` + * + * @return $this + */ + public function setAlias(string $alias): this { + parent::setAlias($alias); + + if (strlen($this->getName()) > 1) { + $this->negativeAlias = "no-{$this->getName()}"; + } + + return $this; + } + + /** + * Set whether the `Flag` is stackable or not. + * + * @param bool $stackable + * + * @return $this + */ + public function setStackable(bool $stackable): this { + $this->stackable = $stackable; + + return $this; + } +} \ No newline at end of file diff --git a/src/Titon/Console/InputDefinition/Option.hh b/src/Titon/Console/InputDefinition/Option.hh new file mode 100644 index 0000000..9af5c9d --- /dev/null +++ b/src/Titon/Console/InputDefinition/Option.hh @@ -0,0 +1,17 @@ + { + +} \ No newline at end of file diff --git a/src/Titon/Console/InputLexer.hh b/src/Titon/Console/InputLexer.hh new file mode 100644 index 0000000..2035a01 --- /dev/null +++ b/src/Titon/Console/InputLexer.hh @@ -0,0 +1,259 @@ + { + + /** + * Data structure of all items that have yet to be retrieved. + * + * @var array + */ + public Vector $items; + + /** + * The current position in the `items` of the lexer. + * + * @var int + */ + protected int $position = 0; + + /** + * The current length of avaiable values remaining in the lexer. + * + * @var int + */ + protected int $length = 0; + + /** + * The current value the lexer is pointing to. + * + * @var \Titon\Console\RawInput + */ + protected RawInput $current; + + /** + * Whether the lexer is on its first item or not. + * + * @var bool + */ + protected bool $first = true; + + /** + * Construct a new `InputLexer` given the provided structure of inputs. + * + * @param array $items The items to traverse through + */ + public function __construct(Vector $items) { + $this->items = $items; + $this->length = $items->count(); + $this->current = shape( + 'value' => '', + 'raw' => '' + ); + } + + /** + * Retrieve the current item the lexer is pointing to. + * + * @return \Titon\Console\RawInput + */ + public function current(): RawInput { + return $this->current; + } + + /** + * Return whether the lexer has reached the end of its parsable items or not. + * + * @return bool + */ + public function end(): bool { + return ($this->position + 1) == $this->length; + } + + /** + * If the current item is a string of short input values or the string contains + * a value a flag is representing, separate them and add them to the available + * items to parse. + * + * @return void + */ + private function explode(): void { + if (!$this->isShort($this->current['raw']) || strlen($this->current['value']) <= 1) { + return; + } + + $exploded = str_split($this->current['value']); + + $this->current = shape( + 'value' => array_pop($exploded), + 'raw' => '-' . $this->current['value'] + ); + + foreach ($exploded as $piece) { + $this->unshift('-' . $piece); + } + } + + /** + * Return whether the given value is representing notation for an argument. + * + * @param string $value The value to check + * + * @return bool + */ + public function isArgument(string $value): bool { + return $this->isShort($value) || $this->isLong($value); + } + + /** + * Determine if the given value is representing a long argument (i.e., --foo). + * + * @param string $value The value to check + * + * @return bool + */ + public function isLong(string $value): bool { + return (0 === strncmp($value, '--', 2)); + } + + /** + * Determine if the given value is representing a short argument (i.e., -f). + * + * @param string $value The value to check + * + * @return bool + */ + public function isShort(string $value): bool { + return !$this->isLong($value) && (0 === strncmp($value, '-', 1)); + } + + /** + * Retrieve the current position of the lexer. + * + * @return int + */ + public function key(): int { + return $this->position; + } + + /** + * Progress the lexer to its next item (if available). + * + * @return void + */ + public function next(): void { + if ($this->valid()) { + $this->shift(); + } + } + + /** + * Peek ahead to the next avaiable item without progressing the lexer. + * + * @return \Titon\Console\RawInput|null + */ + public function peek(): ?RawInput { + if (count($this->items) > 0) { + return $this->processInput($this->items[0]); + } + + return null; + } + + /** + * Create and return RawInput given a raw string value. + * + * @param string $input The value to create RawInput for + * + * @return \Titon\Console\RawInput + */ + public function processInput(string $input): RawInput { + $raw = $input; + $value = $input; + + if ($this->isLong($input)) { + $value = substr($input, 2); + } else { + if ($this->isShort($input)) { + $value = substr($input, 1); + } + } + + return shape( + 'raw' => $raw, + 'value' => $value + ); + } + + /** + * {@inheritdoc} + */ + public function rewind(): void { + $this->shift(); + if ($this->first) { + $this->position = 0; + $this->first = false; + } + } + + /** + * Progress the lexer to its next avaiable item. If the item contains a value + * an argument is representing, separate them and add the value back to the + * available items to parse. + * + * @return void + */ + public function shift(): void { + $key = array_shift($this->items); + + $matches = []; + if (preg_match('#\A([^\s\'"=]+)=(.+?)$#', $key, $matches)) { + $key = $matches[1]; + array_unshift($this->items, $matches[2]); + } else { + $this->position++; + } + + if (is_null($key)) { + return; + } + + $this->current = $this->processInput($key); + + $this->explode(); + } + + /** + * Add an item back to the items that have yet to be parsed. + * + * @param string $item The item to add + * + * @return void + */ + public function unshift(string $item): void { + array_unshift($this->items, $item); + $this->length++; + } + + /** + * Return whether or not the lexer has any more items to parse. + * + * @return bool + */ + public function valid(): bool { + return ($this->position < $this->length); + } +} \ No newline at end of file diff --git a/src/Titon/Console/Output.hh b/src/Titon/Console/Output.hh new file mode 100644 index 0000000..53ac16b --- /dev/null +++ b/src/Titon/Console/Output.hh @@ -0,0 +1,325 @@ +stderr = fopen(Output::STREAM_STDERR, 'w'); + $this->stdout = fopen(Output::STREAM_STDOUT, 'w'); + $this->verbosity = $verbosity; + + if (is_null(self::$instance)) { + self::$instance = $this; + } + } + + /** + * Remove a specific element's style. + * + * @param string $element The element to remove + * + * @return $this + */ + public function clearStyle(string $element): this { + $this->styles->remove($element); + + return $this; + } + + /** + * Send output to the error stream. + * + * @param string $output The contents to output + * @param int $newLines The number of new lines to append to the output + * @param int $verbosity The verbosity level of the output + * @param string $newlineChar The new line character to use + * @return $this + */ + public function error(string $output = '', int $newLines = 1, int $verbosity = Output::VERBOSITY_NORMAL, string $newlineChar = Output::LF): this { + if (!$this->shouldOutput($verbosity)) { + return $this; + } + + $output = $this->format($output); + $output .= str_repeat("\n", $newLines); + + fwrite($this->stderr, $output); + + return $this; + } + + /** + * Set flag if ANSI output should be forced. + * + * @param bool $suppress If ANSI should be forced + * + * @return $this + */ + public function setForceAnsi(bool $force): this { + $this->forceAnsi = $force; + + return $this; + } + + /** + * Format contents by parsing the style tags and applying necessary formatting. + * + * @param string $message The message to format + * + * @string + */ + public function format(string $message): string { + $parsedTags = $this->parseTags($message); + $output = $message; + + foreach ($parsedTags as $xmlTag) { + if (!is_null($this->styles->get($xmlTag))) { + $outputAnsi = $this->isAnsiAllowed(); + $style = $this->styles[$xmlTag]; + $output = $style->format($xmlTag, $output, $outputAnsi); + + $matches = []; + $output = preg_replace_callback('#<[\w-]+?>.*<\/[\w-]+?>#', $matches ==> { + if ($outputAnsi) { + return sprintf("%s%s%s", $style->getEndCode(), $this->format($matches[0]), $style->getStartCode()); + } + + return sprintf("%s", $this->format($matches[0])); + }, $output); + } + } + + return $output; + } + + /** + * Detect the current state of ANSI. + * + * @return bool + */ + public function isAnsiAllowed(): bool { + $allowed = false; + + if ($this->forceAnsi) { + $allowed = true; + } else if ($this->suppressAnsi) { + $allowed = false; + } else if (SystemFactory::factory()->supportsAnsi()) { + $allowed = true; + } + + return $allowed; + } + + /** + * Create and return the singleton instance. + * + * @return \Titon\Console\Output + */ + public static function getInstance(): Output { + if (is_null(self::$instance)) { + self::$instance = new Output(); + } + + return self::$instance; + } + + /** + * Send output to the standard output stream. + * + * @param string $output The contents to output + * @param int $newLines The number of new lines to append to the output + * @param int $verbosity The verbosity level of the output + * @param string $newlineChar The new line character to use + * @return $this + */ + public function out(string $output = '', int $newLines = 1, int $verbosity = Output::VERBOSITY_NORMAL, string $newlineChar = Output::LF): this { + if (!$this->shouldOutput($verbosity)) { + return $this; + } + + $output = $this->format($output); + $output .= str_repeat($newlineChar, $newLines); + + fwrite($this->stdout, $output); + + return $this; + } + + /** + * Parse all available style tags from the given contents. + * + * @param string $stringToParse The contents to parse + * + * @return Set + */ + protected function parseTags(string $stringToParse): Set { + $tagsMatched = []; + preg_match_all('#<([\w-]*?)>#', $stringToParse, $tagsMatched); + + return new Set($tagsMatched[1]); + } + + /** + * Basic `Exception` renderer to handle outputting of uncaught exceptions + * thrown in `Command` objects. + * + * @param Exception $exception The `Exception` thrown + */ + public function renderException(Exception $exception): void { + $class = explode("\\", get_class($exception)); + $class = $class[count($class) - 1]; + + $message = explode('{{BREAK}}', wordwrap($exception->getMessage(), 40, "{{BREAK}}")); + array_unshift($message, "[$class]"); + + $length = max(array_map($key ==> strlen($key), $message)); + + $this->error(Output::LF); + $this->error(" " . str_pad("", $length) . " "); + + foreach ($message as $line) { + $line = str_pad($line, $length); + $this->error(" $line "); + } + + $this->error(" " . str_pad("", $length) . " "); + $this->error(Output::LF); + } + + /** + * Currently unused: Send the response through the given output stream. + */ + public function send(): void { + } + + /** + * Assign a `StyleDefinition` to an XML tag. + * + * @param string $element The element to assign + * @param StyleDefinition $format The style to apply to the given tag + */ + public function setStyle(string $element, StyleDefinition $format): this { + $this->styles[$element] = $format; + + return $this; + } + + /** + * Set flag if ANSI output should be suppressed. + * + * @param bool $suppress If ANSI should be suppressed + * + * @return $this + */ + public function setSuppressAnsi(bool $suppress): this { + $this->suppressAnsi = $suppress; + + return $this; + } + + /** + * Set the global verbosity of the `Output`. + * + * @param int $verbosity The verbosity to set + * + * @return $this + */ + public function setVerbosity(int $verbosity): this { + $this->verbosity = $verbosity; + + return $this; + } + + /** + * Determine how the given verbosity compares to the class's verbosity level. + * + * @param int $verbosity The verbosity to check + * + * @return bool + */ + protected function shouldOutput(int $verbosity): bool { + return ($verbosity <= $this->verbosity); + } + +} diff --git a/src/Titon/Console/StyleDefinition.hh b/src/Titon/Console/StyleDefinition.hh new file mode 100644 index 0000000..2565933 --- /dev/null +++ b/src/Titon/Console/StyleDefinition.hh @@ -0,0 +1,222 @@ + + */ + protected Map $bgColorsMap = Map { + 'black' => "40", + 'red' => "41", + 'green' => "42", + 'brown' => "43", + 'blue' => "44", + 'magenta' => "45", + 'cyan' => "46", + 'white' => "47", + }; + + /** + * The foreground color to apply. + * + * @var string + */ + private string $fgColor; + + /** + * Data structure containing available foreground colors. + * + * @var Map + */ + protected Map $fgColorsMap = Map { + 'black' => '0;30', + 'dark_gray' => '1;30', + 'blue' => '0;34', + 'light_blue' => '1;34', + 'green' => '0;32', + 'light_green' => '1;32', + 'cyan' => '0;36', + 'light_cyan' => '1;36', + 'red' => '0;31', + 'light_red' => '1;31', + 'purple' => '0;35', + 'light_purple' => '1;35', + 'brown' => '0;33', + 'yellow' => '1;33', + 'light_gray' => '0;37', + 'white' => '1;37', + }; + + /** + * The various effects to apply. + * + * @var Vector + */ + private Vector $effectsList; + + /** + * Data structure containing available effects. + * + * @var Map + */ + protected Map $effectsMap = Map { + 'defaults' => 0, + 'bold' => 1, + 'underline' => 4, + 'blink' => 5, + 'reverse' => 7, + 'conceal' => 8, + }; + + /** + * Create a new `StyleDefinition`. + * + * @param string $fgColor The foreground color of the style + * @param string $bgColor The background color of the style + * @param Vector $effects The effects of the style + */ + public function __construct(string $fgColor, string $bgColor = '', Vector $effects = Vector {}) { + $this->fgColor = $fgColor; + $this->bgColor = $bgColor; + $this->effectsList = $effects; + } + + /** + * Format the contents between the given XML tag with the style definition. + * + * @param string $xmlTag The XML tag + * @param string $value The contents to format + * @param bool $ansiSupport If we should style the output with ANSI output + * + * @return string + */ + public function format(string $xmlTag, string $value, bool $ansiSupport = true): string { + $values = $this->getValueBetweenTags($xmlTag, $value); + $retval = $value; + foreach ($values as $val) { + $valueReplaced = '<' . $xmlTag . '>' . $val . ''; + + if ($ansiSupport === false) { + $retval = str_replace($valueReplaced, $val, $retval); + continue; + } + + $valueResult = $this->replaceTagColors($val); + + $retval = str_replace($valueReplaced, $valueResult, $retval); + } + + return $retval; + } + + /** + * Retrieve the background color code of the `StyleDefinition`. + * + * @return string + */ + public function getBgColorCode(): string { + return $this->bgColorsMap->get($this->bgColor) ?: ''; + } + + /** + * Retrieve the effects code of the `StyleDefinition`. + * + * @return string + */ + public function getEffectCode(string $effect): int { + return $this->effectsMap[$effect]; + } + + /** + * Retrieve the code to end the `StyleDefinition`. + * + * @return string + */ + public function getEndCode(): string { + return "\033[0m"; + } + + /** + * Retrieve the foreground color code of the `StyleDefinition`. + * + * @return string + */ + public function getFgColorCode(): string { + return $this->fgColorsMap->get($this->fgColor) ?: ''; + } + + /** + * Retrieve the string of effects to apply for the `StyleDefinition`. + * + * @return string + */ + public function getParsedStringEffects(): string { + $effects = Vector {}; + + if ($this->effectsList->isEmpty()) { + foreach ($this->effectsList as $effect) { + $effects[] = $this->getEffectCode($effect); + } + } + + return implode(';', $effects); + } + + /** + * Retrieve the start code of the `StyleDefinition`. + * + * @return string + */ + public function getStartCode(): string { + $colors = $this->getBgColorCode() . ';' . $this->getFgColorCode(); + $effects = $this->getParsedStringEffects(); + $effects = $effects ? ';' . $effects : ''; + + return sprintf("\033[%sm", $colors . $effects); + } + + /** + * Parse and return contents between the given XML tag. + * + * @return string + */ + public function getValueBetweenTags(string $tag, string $stringToParse): array { + $regexp = '#<' . $tag . '>(.*?)#s'; + $valuesMatched = []; + preg_match_all($regexp, $stringToParse, $valuesMatched); + + return $valuesMatched[1]; + } + + /** + * Return the styled text. + * + * @param string $text The text to style + * + * @return string + */ + protected function replaceTagColors(string $text): string { + return sprintf("%s%s%s", $this->getStartCode(), $text, $this->getEndCode()); + } +} \ No newline at end of file diff --git a/src/Titon/Console/System.hh b/src/Titon/Console/System.hh new file mode 100644 index 0000000..dbf9dcf --- /dev/null +++ b/src/Titon/Console/System.hh @@ -0,0 +1,45 @@ +forceAnsi = $force; + + return $this; + } + + /** + * Determines if the current command line window supports ANSI output. + * + * @return bool + */ + public function hasAnsiSupport(): bool { + if ($this->forceAnsi === true) { + return true; + } + + return $this->supportsAnsi(); + } + + /** + * {@inheritdoc} + */ + public function isPiped(): bool { + $shellPipe = getenv('SHELL_PIPE'); + + if ($shellPipe !== false) { + return filter_var($shellPipe, FILTER_VALIDATE_BOOLEAN); + } else { + return function_exists('posix_isatty') && !@posix_isatty(STDOUT); + } + } + +} diff --git a/src/Titon/Console/System/LinuxSystem.hh b/src/Titon/Console/System/LinuxSystem.hh new file mode 100644 index 0000000..bb0ca31 --- /dev/null +++ b/src/Titon/Console/System/LinuxSystem.hh @@ -0,0 +1,38 @@ + + */ + protected Vector $modeOutput = Vector {}; + + /** + * {@inheritdoc} + */ + public function getHeight(): int { + $this->getStats(); + + return (int) $this->modeOutput[0]; + } + + /** + * {@inheritdoc} + */ + public function getWidth(): int { + $this->getStats(); + + return (int) $this->modeOutput[1]; + } + + /** + * Get the width and height of the current terminal window from the `mode` + * command. + * + * @return Vector + */ + public function getStats(): Vector { + if ($this->modeOutput->isEmpty()) { + $output = ''; + exec('mode', $output); + $output = implode("\n", $output); + $matches = []; + preg_match_all('/.*:\s*(\d+)/', $output, $matches); + $this->modeOutput = new Vector((count($matches[1]) > 0) ? $matches[1] : []); + } + + return $this->modeOutput; + } + + /** + * {@inheritdoc} + */ + public function supportsAnsi(): bool { + return (getenv('ANSICON') === true || getenv('ConEmuANSI') === 'ON'); + } + +} diff --git a/src/Titon/Console/Table.hh b/src/Titon/Console/Table.hh new file mode 100644 index 0000000..f42bbe4 --- /dev/null +++ b/src/Titon/Console/Table.hh @@ -0,0 +1,63 @@ + $row A Vector containing the row of data + * + * @return $this + */ + public function addRow(Vector $row): this; + + /** + * Build and return the markup for the `Table`. + * + * @return string + */ + public function render(): string; + + /** + * Set the data of the table with a Vector of column name and value Maps. + * This method overwrites any existing rows in the table. + * + * @param Vector> $data A Vector containing Maps of column + * name and data key-value pairs. + * + * @return $this + */ + public function setData(Vector> $data): this; + + /** + * Set the column names for the table. + * + * @param Vector $headers A Vector containing column names + * + * @return $this + */ + public function setHeaders(Vector $headers): this; + + /** + * Set the data for the rows in the table with a Vector containing a Vector + * for each row in the table. + * + * @param Vector The Vector containin the row data + * + * @return $this + */ + public function setRows(Vector> $rows): this; +} \ No newline at end of file diff --git a/src/Titon/Console/Table/AbstractTable.hh b/src/Titon/Console/Table/AbstractTable.hh new file mode 100644 index 0000000..32bf80c --- /dev/null +++ b/src/Titon/Console/Table/AbstractTable.hh @@ -0,0 +1,128 @@ + + */ + protected Vector $columnWidths = Vector {}; + + /** + * Data structure holding the header names of each column. + * + * @var Vector + */ + protected Vector $headers = Vector {}; + + /** + * Data structure holding the data for each row in the table. + * + * @var Vector> + */ + protected Vector> $rows = Vector {}; + + /** + * Append a new row of data to the end of the existing rows. + * + * @param Vector $row Vector containing the row data + * + * @return $this + */ + public function addRow(Vector $row): this { + $this->setColumnWidths($row); + $this->rows[] = $row; + + return $this; + } + + /** + * Given the row of data, adjust the column width accordingly so that the + * columns width is that of the maximum data field size. + * + * @param Vector $row Vector containing the row data + * + * @return $this + */ + protected function setColumnWidths(Vector $row): void { + foreach ($row as $index => $value) { + $width = strlen($value); + $currentWidth = 0; + + if ($this->columnWidths->containsKey($index)) { + $currentWidth = $this->columnWidths[$index]; + } + + if ($width > $currentWidth) { + if ($this->columnWidths->count() === $index) { + $this->columnWidths[] = $width; + } else { + $this->columnWidths[$index] = $width; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function setData(Vector> $data): this { + $rows = Vector {}; + $headers = Vector {}; + + foreach ($data as $row) { + foreach ($row as $column => $value) { + if ($headers->linearSearch($column) < 0) { + $headers[] = $column; + } + } + + $rows[] = $row->values(); + } + + $this->setRows($rows); + $this->setHeaders($headers); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setHeaders(Vector $headers): this { + $this->setColumnWidths($headers); + $this->headers = $headers; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setRows(Vector> $rows): this { + $this->rows->clear(); + + foreach ($rows as $row) { + $this->addRow($row); + } + + return $this; + } + +} diff --git a/src/Titon/Console/Table/AsciiTable.hh b/src/Titon/Console/Table/AsciiTable.hh new file mode 100644 index 0000000..115b8c3 --- /dev/null +++ b/src/Titon/Console/Table/AsciiTable.hh @@ -0,0 +1,150 @@ + + */ + protected Map $characters = Map { + 'corner' => '+', + 'line' => '-', + 'header_line' => '=', + 'border' => '|', + 'padding' => ' ', + }; + + /** + * The integer length of the width each row should be. + * + * @var int|null + */ + protected int $rowWidth = 0; + + /** + * Given a string value and a padding string, return the value with the pad + * appended and prepended to the value. + * + * @param string $value The value to pad + * @param string $pad The string of characters to pad the value with + * + * @return string + */ + protected function pad(string $value, string $pad = ''): string { + return "$pad$value$pad"; + } + + /** + * Build a border for the width of the row width for the class and using the + * class's `characters`. + * + * @return string + */ + protected function buildBorder(): string { + if (is_null($this->border)) { + $this->border = '+'; + + foreach ($this->columnWidths as $width) { + $this->border .= $this->characters['padding']; + $this->border .= str_repeat('-', $width); + $this->border .= $this->characters['padding']; + $this->border .= '+'; + } + } + + return $this->border; + } + + /** + * Build a single cell of the table given the data and the key of the column + * the data should go into. + * + * @param string $value The value of the cell + * @param int $key The index of the column + * + * @return string + */ + protected function buildCell(string $value, int $key): string { + return $this->pad(str_pad($value, $this->columnWidths->get($key)), $this->characters['padding']); + } + + /** + * Given a Vector of data, build a single row of the table. + * + * @param Vector The row data + * + * @return string + */ + protected function buildRow(Vector $data): string { + $row = []; + + foreach ($data as $index => $value) { + $row[] = $this->buildCell((string) $value, $index); + } + + $row = $this->pad(implode($this->characters['border'], $row), $this->characters['border']); + + return $row; + } + + /** + * Retrieve the width that each row in the table should be. + * + * @return int + */ + protected function getRowWidth(): int { + if (is_null($this->rowWidth)) { + if (!is_null($this->rows[0])) { + $this->rowWidth = strlen($this->buildRow($this->rows[0])); + } + } + + return $this->rowWidth; + } + + /** + * Build the table and return its markup. + * + * @return string + */ + public function render(): string { + $output = Vector {}; + + if ($header = $this->buildRow($this->headers)) { + $output[] = $this->buildBorder(); + $output[] = $header; + } + + $output[] = $this->buildBorder(); + + foreach ($this->rows as $row) { + $output[] = $this->buildRow($row); + } + + $output[] = $this->buildBorder(); + + return implode("\n", $output); + } + +} diff --git a/src/Titon/Console/Table/TabDelimitedTable.hh b/src/Titon/Console/Table/TabDelimitedTable.hh new file mode 100644 index 0000000..ade9245 --- /dev/null +++ b/src/Titon/Console/Table/TabDelimitedTable.hh @@ -0,0 +1,34 @@ +headers); + + foreach ($this->rows as $row) { + $output[] = implode("\t", $row); + } + + return trim(implode("\n", $output)); + } + +} diff --git a/src/Titon/Console/Tree.hh b/src/Titon/Console/Tree.hh new file mode 100644 index 0000000..cfcb201 --- /dev/null +++ b/src/Titon/Console/Tree.hh @@ -0,0 +1,39 @@ +data = $data; + } + } + + /** + * Recursively build the tree and each branch and prepend necessary markup + * for the output. + * + * @param \Titon\Console\TreeData $tree The tree or branch to build + * @param string $prefix The markup prefix for the tree / branch + * + * @return string + */ + abstract protected function build(TreeData $tree, string $prefix = ''): string; + + /** + * Retrieve the data of the tree. + * + * @return \Titon\Console\TreeData + */ + public function getData(): TreeData { + return $this->data; + } + + /** + * Render the tree. + * + * @return string + */ + public function render(): string { + return $this->build($this->data); + } + + /** + * {@inheritdoc} + */ + public function setData(TreeData $data): this { + $this->data = $data; + + return $this; + } + +} diff --git a/src/Titon/Console/Tree/AsciiTree.hh b/src/Titon/Console/Tree/AsciiTree.hh new file mode 100644 index 0000000..5358cc5 --- /dev/null +++ b/src/Titon/Console/Tree/AsciiTree.hh @@ -0,0 +1,63 @@ +data; + } + + $output = []; + $keys = array_keys($tree); + $branch = array_values($tree); + + for ($i = 0, $count = count($branch); $i < $count; ++$i) { + $itemPrefix = $prefix; + $next = $branch[$i]; + + if ($i === $count - 1) { + if (is_array($next)) { + $itemPrefix .= '└─┬ '; + } else { + $itemPrefix .= '└── '; + } + } else { + if (is_array($next)) { + $itemPrefix .= '├─┬ '; + } else { + $itemPrefix .= '├── '; + } + } + + if (is_array($branch[$i])) { + $output[] = $itemPrefix . (string) $keys[$i]; + } else { + $output[] = $itemPrefix . (string) $branch[$i]; + } + + if (is_array($next)) { + $output[] = $this->build($next, $prefix . ($i == $count - 1 ? ' ' : '| ')); + } + } + + return implode("\n", $output); + } + +} diff --git a/src/Titon/Console/Tree/MarkdownTree.hh b/src/Titon/Console/Tree/MarkdownTree.hh new file mode 100644 index 0000000..a7352f0 --- /dev/null +++ b/src/Titon/Console/Tree/MarkdownTree.hh @@ -0,0 +1,44 @@ +data; + } + + $output = []; + + foreach ($tree as $label => $branch) { + if (is_string($branch)) { + $label = $branch; + } + + $output[] = "$prefix- $label"; + + if (is_array($branch)) { + $output[] = $this->build($branch, "$prefix "); + } + } + + return implode("\n", $output); + } + +} diff --git a/src/Titon/Console/UserInput.hh b/src/Titon/Console/UserInput.hh new file mode 100644 index 0000000..3b0db92 --- /dev/null +++ b/src/Titon/Console/UserInput.hh @@ -0,0 +1,25 @@ + implements UserInput { + + /** + * Input values accepted to continue. + * + * @var Map + */ + protected Map $acceptedValues = Map {}; + + /** + * Default value if input given is empty. + * + * @var string + */ + protected string $default = ''; + + /** + * `Input` object used for retrieving user input. + * + * @var \Titon\Console\Input + */ + protected Input $input; + + /** + * `Output` object used for sending output. + * + * @var \Titon\Console\Output + */ + protected Output $output; + + /** + * If the input should be accepted strictly or not. + * + * @var bool + */ + protected bool $strict = true; + + /** + * Construct a new `UserInput` object. + */ + public function __construct(Input $input, Output $output) { + $this->input = $input; + $this->output = $output; + } + + /** + * Set the values accepted by the user. + * + * @param Map $choices Accepted values + * + * @return $this + */ + public function setAcceptedValues(Map $choices = Map {}): this { + $this->acceptedValues = $choices; + + return $this; + } + + /** + * Set the default value to use when input is empty. + * + * @param string $default The default value + * + * @return $this + */ + public function setDefault(string $default): this { + $this->default = $default; + + return $this; + } + + /** + * Set if the prompt should run in strict mode. + * + * @param bool $strict Strict value + * + * @return $this + */ + public function setStrict(bool $strict): this { + $this->strict = $strict; + + return $this; + } + +} diff --git a/src/Titon/Console/UserInput/Confirm.hh b/src/Titon/Console/UserInput/Confirm.hh new file mode 100644 index 0000000..1ccb6d0 --- /dev/null +++ b/src/Titon/Console/UserInput/Confirm.hh @@ -0,0 +1,101 @@ + { + + /** + * The message to be appended to the prompt message containing the accepted + * values. + * + * @var string + */ + protected string $message = ''; + + /** + * @todo + */ + public function __construct(Input $input, Output $output) { + parent::__construct($input, $output); + + $this->acceptedValues = Map { + 'y' => true, + 'yes' => true, + 'n' => false, + 'no' => false + }; + } + + /** + * Prompt the user for input and return the boolean value if the user has + * confirmed or not. + * + * @param string $message The message to prompt the user with + * + * @return bool + */ + public function confirmed(string $message): bool { + $input = $this->prompt($message); + $retval = $this->acceptedValues[strtolower($input)]; + + invariant(is_bool($retval), "Must be a boolean value."); + + return $retval; + } + + /** + * {@inheritdoc} + */ + public function prompt(string $message): string { + $message = "$message $this->message"; + + do { + $this->output->out("$message ", Output::VERBOSITY_NORMAL, 0); + $input = $this->input->getUserInput(); + + if ($input === '' && $this->default !== '') { + $input = $this->default; + } + } while (!$this->acceptedValues->contains(strtolower($input))); + + return $input; + } + + /** + * {@inheritdoc} + */ + <<__Override>> + public function setDefault(string $default = ''): this { + switch (strtolower($default)) { + case 'y': + case 'yes': + $this->default = $default; + $message = " [Y/n]"; + break; + case 'n': + case 'no': + $this->default = $default; + $message = " [y/N]"; + break; + default: + $message = " [y/n]"; + break; + } + + $this->message = $message; + + return $this; + } + +} diff --git a/src/Titon/Console/UserInput/Menu.hh b/src/Titon/Console/UserInput/Menu.hh new file mode 100644 index 0000000..dcd3aa3 --- /dev/null +++ b/src/Titon/Console/UserInput/Menu.hh @@ -0,0 +1,76 @@ + { + + /** + * The message to present at the prompt. + * + * @var string + */ + protected string $message = ''; + + /** + * {@inheritdoc} + */ + public function prompt(string $prompt): mixed { + $keys = $this->acceptedValues->keys(); + $values = $this->acceptedValues->values(); + + if ($this->message !== '') { + $this->output->out($this->message); + } + + foreach ($values as $index => $item) { + $this->output->out(sprintf(' %d. %s', $index + 1, (string)$item)); + } + + $this->output->out(); + + while (true) { + $this->output->out("$prompt ", Output::VERBOSITY_NORMAL, 0); + $input = $this->input->getUserInput(); + + if (is_numeric($input)) { + $input = (int)$input; + $input--; + + if (!is_null($values[$input])) { + return $keys[$input]; + } + + if ($input < 0 || $input >= $values->count()) { + $this->output->error('Invalid menu selection: out of range'); + } + } + } + } + + /** + * Set the message presented to the user before the options are displayed. + * + * @param string $message The message to display + * + * @return $this + */ + public function setMessage(string $message): this { + $this->message = $message; + + return $this; + } + +} diff --git a/src/Titon/Console/UserInput/Prompt.hh b/src/Titon/Console/UserInput/Prompt.hh new file mode 100644 index 0000000..f346239 --- /dev/null +++ b/src/Titon/Console/UserInput/Prompt.hh @@ -0,0 +1,99 @@ + { + + /** + * If the prompt is set to show value hints, this string contains those hints + * to output when presenting the user with the prompt. + * + * @var string + */ + protected string $hint = ''; + + /** + * {@inheritdoc} + */ + public function prompt(string $message): mixed { + $keys = $this->acceptedValues->keys(); + $values = $this->acceptedValues->values(); + + if ($this->hint !== '') { + $message .= " $this->hint"; + } + + while (true) { + $this->output->out($message, Output::VERBOSITY_NORMAL, 0); + $input = $this->input->getUserInput(); + + if ($input === '' && $this->default !== '') { + $input = $this->default; + } + + if ($this->acceptedValues->isEmpty()) { + return $input; + } + + if (is_numeric($input)) { + $input = (int)$input; + $input--; + + if (!is_null($values[$input])) { + return $keys[$input]; + } + + if ($input < 0 || $input >= $values->count()) { + $this->output->error('Invalid menu selection: out of range'); + } + } else { + foreach ($values as $index => $val) { + if ($this->strict) { + if ($input === $val) { + return $keys[$index]; + } + } else { + if (strtolower($input) === strtolower($val)) { + return $keys[$index]; + } + } + } + } + } + } + + /** + * Set whether the message presented at the prompt should show the predetermined + * accepted values (if any). + * + * @param bool $showHint Boolean to show the hints or not + * + * @return $this + */ + public function showHints(bool $showHint = true): this { + if ($showHint === true) { + $output = $this->acceptedValues->toVector(); + $output = implode('/', $output); + + $this->hint = "[$output]"; + } else { + $this->hint = ''; + } + + return $this; + } + +} diff --git a/src/Titon/Console/bootstrap.hh b/src/Titon/Console/bootstrap.hh new file mode 100644 index 0000000..d8e4c4d --- /dev/null +++ b/src/Titon/Console/bootstrap.hh @@ -0,0 +1,32 @@ +; + type CommandList = Vector; + type FeedbackVariables = shape( + 'message' => string, + 'percent' => int, + 'elapsed' => string, + 'estimated' => string, + ); + type RawInput = shape( + 'raw' => string, + 'value' => string + ); + type StyleMap = Map; + type TreeData = array; +} \ No newline at end of file diff --git a/src/Titon/Console/composer.json b/src/Titon/Console/composer.json new file mode 100644 index 0000000..2c8e735 --- /dev/null +++ b/src/Titon/Console/composer.json @@ -0,0 +1,36 @@ +{ + "name": "titon/console", + "type": "library", + "description": "The console packages provides support for command line applications that accept commands, arguments, options, and flags.", + "keywords": ["titon", "console", "cli", "command line", "command", "option", "argument", "flag", "shell"], + "homepage": "http://titon.io", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Miles Johnson", + "homepage": "http://milesj.me" + }, + { + "name": "Alex Phillips", + "homepage": "http://wootables.com" + } + ], + "support": { + "irc": "irc://irc.freenode.org/titon", + "source": "https://github.com/titon/console" + }, + "require": { + "hhvm": ">=3.6.0", + "titon/common": "*", + "titon/kernel": "*", + "titon/utility": "*" + }, + "autoload": { + "psr-4": { + "Titon\\Console\\": "" + }, + "files": [ + "bootstrap.hh" + ] + } +} diff --git a/src/Titon/Console/readme.md b/src/Titon/Console/readme.md new file mode 100644 index 0000000..c091bb8 --- /dev/null +++ b/src/Titon/Console/readme.md @@ -0,0 +1,10 @@ +# Console # +[![Project Titon](https://img.shields.io/badge/project-titon-82667d.svg?style=flat)](http://titon.io) +[![Latest Version](https://img.shields.io/packagist/v/titon/console.svg?style=flat)](https://packagist.org/packages/titon/console) +[![Total Downloads](https://img.shields.io/packagist/dm/titon/console.svg?style=flat)](https://packagist.org/packages/titon/console) +[![License](https://img.shields.io/packagist/l/titon/console.svg?style=flat)](https://github.com/titon/framework/blob/master/license.md) + +This repository is read-only. Please refer to the official framework repository for any issues, pull requests, or documentation. + +* [Framework](https://github.com/titon/framework) +* [Documentation](https://github.com/titon/framework/blob/master/docs/en/packages/console/index.md) diff --git a/tests/Titon/Console/CommandTest.hh b/tests/Titon/Console/CommandTest.hh new file mode 100644 index 0000000..0cfc102 --- /dev/null +++ b/tests/Titon/Console/CommandTest.hh @@ -0,0 +1,30 @@ +setInput($args); + $command->configure(); + $command->registerInput(); + + $args->parse(); + + $this->assertEquals(3, count($args->getFlags())); + $this->assertEquals(2, count($args->getOptions())); + $this->assertEquals(1, count($args->getArguments())); + $this->assertEquals(1, $args->getFlag('table')->getValue()); + } +} \ No newline at end of file diff --git a/tests/Titon/Console/InputTest.hh b/tests/Titon/Console/InputTest.hh new file mode 100644 index 0000000..15bd92c --- /dev/null +++ b/tests/Titon/Console/InputTest.hh @@ -0,0 +1,168 @@ +input = new Input(); + $this->input->addCommand(new CommandStub($this->input, new Output())); + } + + public function testParseFlags(): void { + /* + * Check basic flag + */ + $this->input->setInput(Vector { + 'demo', + '--foo' + }); + $this->input->addFlag((new Flag('foo'))->alias('f')); + $this->input->parse(); + + $this->assertEquals(1, $this->input->getFlag('foo')->getValue()); + $this->assertEquals(1, $this->input->getFlag('f')->getValue()); + + $this->input->setInput(Vector { + 'demo', + '-f' + }); + $this->input->addFlag((new Flag('foo'))->alias('f')); + $this->input->parse(); + + $this->assertEquals(1, $this->input->getFlag('foo')->getValue()); + $this->assertEquals(1, $this->input->getFlag('f')->getValue()); + + /* + * Check stacked, but different, flags + */ + $this->input->setInput(Vector { + 'demo', + '-fb' + }); + $this->input->addFlag((new Flag('foo'))->alias('f')); + $this->input->addFlag((new Flag('bar'))->alias('b')); + $this->input->parse(); + + $this->assertEquals(1, $this->input->getFlag('foo')->getValue()); + $this->assertEquals(1, $this->input->getFlag('bar')->getValue()); + + /* + * Check stacked flag + */ + $this->input->SetInput(Vector { + 'demo', + '-vvv' + }); + $this->input->addFlag((new Flag('v'))->setStackable(true)); + $this->input->parse(); + + $this->assertEquals(3, $this->input->getFlag('v')->getValue()); + + /* + * Check inverse flags + */ + $this->input->setInput(Vector { + '--no-foo' + }); + + $this->input->addFlag(new Flag('foo')); + $this->input->parse(); + + $this->assertEquals(0, $this->input->getFlag('foo')->getValue()); + } + + public function testParseOptions(): void { + $this->input->setInput(Vector { + 'demo', + '--name', + 'Alex Phillips' + }); + $this->input->addOption((new Option('name'))->alias('n')); + $this->input->addArgument(new Argument('bar', 'Bar!')); + $this->input->parse(); + + $this->assertEquals('Alex Phillips', $this->input->getOption('name')->getValue()); + $this->assertEquals('Alex Phillips', $this->input->getOption('n')->getValue()); + + $this->input->setInput(Vector { + '--name', + 'Alex Phillips', + 'demo' + }); + $this->input->addOption((new Option('name'))->alias('n')); + $this->input->addArgument(new Argument('bar', 'Bar!')); + $this->input->parse(); + + $this->assertEquals('Alex Phillips', $this->input->getOption('name')->getValue()); + $this->assertEquals('Alex Phillips', $this->input->getOption('n')->getValue()); + + $this->input->setInput(Vector { + 'demo', + '-n', + 'Alex Phillips' + }); + $this->input->addOption((new Option('name'))->alias('n')); + $this->input->addArgument(new Argument('bar', 'Bar!')); + $this->input->parse(); + + $this->assertEquals('Alex Phillips', $this->input->getOption('name')->getValue()); + $this->assertEquals('Alex Phillips', $this->input->getOption('n')->getValue()); + + $this->input->setInput(Vector { + 'demo', + '--name="Alex Phillips"' + }); + $this->input->addOption((new Option('name'))->alias('n')); + $this->input->addArgument(new Argument('bar', 'Bar!')); + $this->input->parse(); + + $this->assertEquals('Alex Phillips', $this->input->getOption('name')->getValue()); + $this->assertEquals('Alex Phillips', $this->input->getOption('n')->getValue()); + } + + public function testParseArguments(): void { + $this->input->setInput(Vector { + 'demo', + 'Alex Phillips' + }); + $this->input->addArgument(new Argument('name')); + $this->input->getActiveCommand(); + $this->input->parse(); + + $this->assertEquals('Alex Phillips', $this->input->getArgument('name')->getValue()); + } + + public function testMixedArguments(): void { + $this->input->setInput(Vector { + 'demo', + 'Alex Phillips', + '-fb', + '--baz="woot"' + }); + $this->input->addFlag((new Flag('bar'))->alias('b')); + $this->input->addOption(new Option('baz')); + $this->input->addFlag((new Flag('foo'))->alias('f')); + $this->input->addArgument(new Argument('name')); + + $this->input->getActiveCommand(); + $this->input->parse(); + + $this->assertEquals('demo', $this->input->getActiveCommand()->getName()); + $this->assertEquals('Alex Phillips', $this->input->getArgument('name')->getValue()); + $this->assertEquals(1, $this->input->getFlag('foo')->getValue()); + $this->assertEquals(1, $this->input->getFlag('f')->getValue()); + $this->assertEquals(1, $this->input->getFlag('bar')->getValue()); + $this->assertEquals(1, $this->input->getFlag('b')->getValue()); + $this->assertEquals('woot', $this->input->getOption('baz')->getValue()); + } +} \ No newline at end of file diff --git a/tests/Titon/Test/Stub/Console/CommandStub.hh b/tests/Titon/Test/Stub/Console/CommandStub.hh new file mode 100644 index 0000000..52b0cf1 --- /dev/null +++ b/tests/Titon/Test/Stub/Console/CommandStub.hh @@ -0,0 +1,185 @@ +setName('demo')->setDescription('Provide demos for all built in functionalities.'); + + $this->addFlag(new Flag('table', 'Output a nice sample table')); + + $this->addOption(new Option('tree', 'Output either an `ascii` or `markdown` tree')); + + $this->addFlag(new Flag('greet', 'Output a friendly greeting!')); + $this->addArgument(new Argument('name', 'The name of the person I am greeting.')); + + $this->addOption(new Option('feedback', 'Display either a `progress` bar or a `spinner`.')); + + $this->addFlag(new Flag('menu', 'Present a menu to help guide you through the examples.')); + } + + public function run(): void { + if (($type = $this->getOption('tree', '')) !== '') { + invariant(is_string($type), 'Must be a string.'); + $this->runTreeExample($type); + + } else if ($this->getFlag('table') === 1) { + $this->runTableExample(); + + } else if ($this->getFlag('greet')) { + if (($name = $this->getArgument('name', '')) !== '') { + $this->out(sprintf('Hello, %s!', $name)); + } else { + $this->out('Hello, world!'); + } + + } else if (($type = $this->getOption('feedback', '')) !== '') { + invariant(is_string($type), 'Must be a string.'); + $this->runFeedbackExample($type); + + } else if ($this->getFlag('menu')) { + $this->runUserInputExamples(); + + } else { + $this->runUserInputExamples(); + } + } + + protected function runFeedbackExample(string $type): void { + if ($type === 'progress') { + $feedback = $this->progressBar(100, 'Please wait'); + } else if ($type === 'spinner') { + $feedback = $this->wait(100, 'Please wait '); + } else { + return; + } + + for ($i = 0; $i < 100; $i++) { + $feedback->advance(); + usleep(10000); + } + } + + protected function runTableExample(): void { + $data = Vector{ + Map { + 'Company' => 'Apple', + 'Operating System' => 'Mac OS X', + 'Latest Release' => 'Yosemite', + }, + Map { + 'Company' => 'Microsoft', + 'Operating System' => 'Windows', + 'Latest Release' => '10', + }, + Map { + 'Company' => 'RedHat', + 'Operating System' => 'RHEL', + 'Latest Release' => '7', + }, + }; + + $table = new AsciiTable(); + $table->setData($data); + + $this->out($table->render()); + } + + protected function runTreeExample(string $type): void { + $data = [ + 'Titon' => [ + 'Console' => [ + 'Command', + 'Exception', + 'Feedback', + 'InputDefinition', + 'System', + 'Table', + 'Tree' => [ + 'AbstractTree.hh', + 'AsciiTree.hh', + 'MarkdownTree.hh' + ], + 'UserInput', + ], + 0 => 'Utility' + ], + ]; + + if ($type === 'ascii') { + $tree = new AsciiTree($data); + } else if ($type === 'markdown') { + $tree = new MarkdownTree($data); + } else { + $this->error(sprintf('The table type %s is not supported.', $type)); + + return; + } + + $this->out($tree->render()); + } + + protected function runUserInputExamples(): void { + $confirm = $this->confirm('y'); + if (!$confirm->confirmed('Are you sure you want a menu?')) { + return; + } + + $menu = $this->menu(Map { + '0' => 'ASCII Tree', + '1' => 'Markdown Tree', + '2' => 'Table', + '3' => 'Greeting', + '4' => 'Progress bar', + '5' => 'Spinner', + '6' => 'Repeat this menu', + '7' => 'Quit' + }, 'Please select from the options below'); + + $answer = $menu->prompt('Enter a selection:'); + + switch ((int)$answer) { + case 0: + $this->runTreeExample('ascii'); + break; + case 1: + $this->runTreeExample('markdown'); + break; + case 2: + $this->runTableExample(); + break; + case 3: + $this->out('Hello, world!'); + break; + case 4: + $this->runFeedbackExample('progress'); + break; + case 5: + $this->runFeedbackExample('spinner'); + break; + case 6; + $this->runUserInputExamples(); + break; + default: + $this->out('Bye!'); + return; + } + } +} diff --git a/tests/console.hh b/tests/console.hh new file mode 100644 index 0000000..d1b7c7d --- /dev/null +++ b/tests/console.hh @@ -0,0 +1,34 @@ +setName('Titon Framework'); +$application->setVersion(file_get_contents('../version.md')); +$application->setBanner($banner); +$application->addCommand(new CommandStub()); + +$console = new Console($application, new Pipeline()); +$console->run(new Input(), new Output()); +$console->terminate();