First commit

This commit is contained in:
Theodotos Andreou 2018-01-14 13:10:16 +00:00
commit c6e2478c40
13918 changed files with 2303184 additions and 0 deletions

View file

@ -0,0 +1,452 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
use Consolidation\AnnotatedCommand\Help\HelpDocumentAlter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* AnnotatedCommands are created automatically by the
* AnnotatedCommandFactory. Each command method in a
* command file will produce one AnnotatedCommand. These
* are then added to your Symfony Console Application object;
* nothing else is needed.
*
* Optionally, though, you may extend AnnotatedCommand directly
* to make a single command. The usage pattern is the same
* as for any other Symfony Console command, except that you may
* omit the 'Confiure' method, and instead place your annotations
* on the execute() method.
*
* @package Consolidation\AnnotatedCommand
*/
class AnnotatedCommand extends Command implements HelpDocumentAlter
{
protected $commandCallback;
protected $commandProcessor;
protected $annotationData;
protected $examples = [];
protected $topics = [];
protected $usesInputInterface;
protected $usesOutputInterface;
protected $returnType;
public function __construct($name = null)
{
$commandInfo = false;
// If this is a subclass of AnnotatedCommand, check to see
// if the 'execute' method is annotated. We could do this
// unconditionally; it is a performance optimization to skip
// checking the annotations if $this is an instance of
// AnnotatedCommand. Alternately, we break out a new subclass.
// The command factory instantiates the subclass.
if (get_class($this) != 'Consolidation\AnnotatedCommand\AnnotatedCommand') {
$commandInfo = CommandInfo::create($this, 'execute');
if (!isset($name)) {
$name = $commandInfo->getName();
}
}
parent::__construct($name);
if ($commandInfo && $commandInfo->hasAnnotation('command')) {
$this->setCommandInfo($commandInfo);
$this->setCommandOptions($commandInfo);
}
}
public function setCommandCallback($commandCallback)
{
$this->commandCallback = $commandCallback;
return $this;
}
public function setCommandProcessor($commandProcessor)
{
$this->commandProcessor = $commandProcessor;
return $this;
}
public function commandProcessor()
{
// If someone is using an AnnotatedCommand, and is NOT getting
// it from an AnnotatedCommandFactory OR not correctly injecting
// a command processor via setCommandProcessor() (ideally via the
// DI container), then we'll just give each annotated command its
// own command processor. This is not ideal; preferably, there would
// only be one instance of the command processor in the application.
if (!isset($this->commandProcessor)) {
$this->commandProcessor = new CommandProcessor(new HookManager());
}
return $this->commandProcessor;
}
public function getReturnType()
{
return $this->returnType;
}
public function setReturnType($returnType)
{
$this->returnType = $returnType;
return $this;
}
public function getAnnotationData()
{
return $this->annotationData;
}
public function setAnnotationData($annotationData)
{
$this->annotationData = $annotationData;
return $this;
}
public function getTopics()
{
return $this->topics;
}
public function setTopics($topics)
{
$this->topics = $topics;
return $this;
}
public function setCommandInfo($commandInfo)
{
$this->setDescription($commandInfo->getDescription());
$this->setHelp($commandInfo->getHelp());
$this->setAliases($commandInfo->getAliases());
$this->setAnnotationData($commandInfo->getAnnotations());
$this->setTopics($commandInfo->getTopics());
foreach ($commandInfo->getExampleUsages() as $usage => $description) {
$this->addUsageOrExample($usage, $description);
}
$this->setCommandArguments($commandInfo);
$this->setReturnType($commandInfo->getReturnType());
// Hidden commands available since Symfony 3.2
// http://symfony.com/doc/current/console/hide_commands.html
if (method_exists($this, 'setHidden')) {
$this->setHidden($commandInfo->getHidden());
}
return $this;
}
public function getExampleUsages()
{
return $this->examples;
}
protected function addUsageOrExample($usage, $description)
{
$this->addUsage($usage);
if (!empty($description)) {
$this->examples[$usage] = $description;
}
}
public function helpAlter(\DomDocument $originalDom)
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->appendChild($commandXML = $dom->createElement('command'));
$commandXML->setAttribute('id', $this->getName());
$commandXML->setAttribute('name', $this->getName());
// Get the original <command> element and its top-level elements.
$originalCommandXML = $this->getSingleElementByTagName($dom, $originalDom, 'command');
$originalUsagesXML = $this->getSingleElementByTagName($dom, $originalCommandXML, 'usages');
$originalDescriptionXML = $this->getSingleElementByTagName($dom, $originalCommandXML, 'description');
$originalHelpXML = $this->getSingleElementByTagName($dom, $originalCommandXML, 'help');
$originalArgumentsXML = $this->getSingleElementByTagName($dom, $originalCommandXML, 'arguments');
$originalOptionsXML = $this->getSingleElementByTagName($dom, $originalCommandXML, 'options');
// Keep only the first of the <usage> elements
$newUsagesXML = $dom->createElement('usages');
$firstUsageXML = $this->getSingleElementByTagName($dom, $originalUsagesXML, 'usage');
$newUsagesXML->appendChild($firstUsageXML);
// Create our own <example> elements
$newExamplesXML = $dom->createElement('examples');
foreach ($this->examples as $usage => $description) {
$newExamplesXML->appendChild($exampleXML = $dom->createElement('example'));
$exampleXML->appendChild($usageXML = $dom->createElement('usage', $usage));
$exampleXML->appendChild($descriptionXML = $dom->createElement('description', $description));
}
// Create our own <alias> elements
$newAliasesXML = $dom->createElement('aliases');
foreach ($this->getAliases() as $alias) {
$newAliasesXML->appendChild($dom->createElement('alias', $alias));
}
// Create our own <topic> elements
$newTopicsXML = $dom->createElement('topics');
foreach ($this->getTopics() as $topic) {
$newTopicsXML->appendChild($topicXML = $dom->createElement('topic', $topic));
}
// Place the different elements into the <command> element in the desired order
$commandXML->appendChild($newUsagesXML);
$commandXML->appendChild($newExamplesXML);
$commandXML->appendChild($originalDescriptionXML);
$commandXML->appendChild($originalArgumentsXML);
$commandXML->appendChild($originalOptionsXML);
$commandXML->appendChild($originalHelpXML);
$commandXML->appendChild($newAliasesXML);
$commandXML->appendChild($newTopicsXML);
return $dom;
}
protected function getSingleElementByTagName($dom, $parent, $tagName)
{
// There should always be exactly one '<command>' element.
$elements = $parent->getElementsByTagName($tagName);
$result = $elements->item(0);
$result = $dom->importNode($result, true);
return $result;
}
protected function setCommandArguments($commandInfo)
{
$this->setUsesInputInterface($commandInfo);
$this->setUsesOutputInterface($commandInfo);
$this->setCommandArgumentsFromParameters($commandInfo);
return $this;
}
/**
* Check whether the first parameter is an InputInterface.
*/
protected function checkUsesInputInterface($params)
{
/** @var \ReflectionParameter $firstParam */
$firstParam = reset($params);
return $firstParam && $firstParam->getClass() && $firstParam->getClass()->implementsInterface(
'\\Symfony\\Component\\Console\\Input\\InputInterface'
);
}
/**
* Determine whether this command wants to get its inputs
* via an InputInterface or via its command parameters
*/
protected function setUsesInputInterface($commandInfo)
{
$params = $commandInfo->getParameters();
$this->usesInputInterface = $this->checkUsesInputInterface($params);
return $this;
}
/**
* Determine whether this command wants to send its output directly
* to the provided OutputInterface, or whether it will returned
* structured output to be processed by the command processor.
*/
protected function setUsesOutputInterface($commandInfo)
{
$params = $commandInfo->getParameters();
$index = $this->checkUsesInputInterface($params) ? 1 : 0;
$this->usesOutputInterface =
(count($params) > $index) &&
$params[$index]->getClass() &&
$params[$index]->getClass()->implementsInterface(
'\\Symfony\\Component\\Console\\Output\\OutputInterface'
)
;
return $this;
}
protected function setCommandArgumentsFromParameters($commandInfo)
{
$args = $commandInfo->arguments()->getValues();
foreach ($args as $name => $defaultValue) {
$description = $commandInfo->arguments()->getDescription($name);
$hasDefault = $commandInfo->arguments()->hasDefault($name);
$parameterMode = $this->getCommandArgumentMode($hasDefault, $defaultValue);
$this->addArgument($name, $parameterMode, $description, $defaultValue);
}
return $this;
}
protected function getCommandArgumentMode($hasDefault, $defaultValue)
{
if (!$hasDefault) {
return InputArgument::REQUIRED;
}
if (is_array($defaultValue)) {
return InputArgument::IS_ARRAY;
}
return InputArgument::OPTIONAL;
}
public function setCommandOptions($commandInfo, $automaticOptions = [])
{
$inputOptions = $commandInfo->inputOptions();
$this->addOptions($inputOptions + $automaticOptions, $automaticOptions);
return $this;
}
public function addOptions($inputOptions, $automaticOptions = [])
{
foreach ($inputOptions as $name => $inputOption) {
$description = $inputOption->getDescription();
if (empty($description) && isset($automaticOptions[$name])) {
$description = $automaticOptions[$name]->getDescription();
$inputOption = static::inputOptionSetDescription($inputOption, $description);
}
$this->getDefinition()->addOption($inputOption);
}
}
protected static function inputOptionSetDescription($inputOption, $description)
{
// Recover the 'mode' value, because Symfony is stubborn
$mode = 0;
if ($inputOption->isValueRequired()) {
$mode |= InputOption::VALUE_REQUIRED;
}
if ($inputOption->isValueOptional()) {
$mode |= InputOption::VALUE_OPTIONAL;
}
if ($inputOption->isArray()) {
$mode |= InputOption::VALUE_IS_ARRAY;
}
if (!$mode) {
$mode = InputOption::VALUE_NONE;
}
$inputOption = new InputOption(
$inputOption->getName(),
$inputOption->getShortcut(),
$mode,
$description,
$inputOption->getDefault()
);
return $inputOption;
}
/**
* Returns all of the hook names that may be called for this command.
*
* @return array
*/
public function getNames()
{
return HookManager::getNames($this, $this->commandCallback);
}
/**
* Add any options to this command that are defined by hook implementations
*/
public function optionsHook()
{
$this->commandProcessor()->optionsHook(
$this,
$this->getNames(),
$this->annotationData
);
}
public function optionsHookForHookAnnotations($commandInfoList)
{
foreach ($commandInfoList as $commandInfo) {
$inputOptions = $commandInfo->inputOptions();
$this->addOptions($inputOptions);
foreach ($commandInfo->getExampleUsages() as $usage => $description) {
if (!in_array($usage, $this->getUsages())) {
$this->addUsageOrExample($usage, $description);
}
}
}
}
/**
* {@inheritdoc}
*/
protected function interact(InputInterface $input, OutputInterface $output)
{
$this->commandProcessor()->interact(
$input,
$output,
$this->getNames(),
$this->annotationData
);
}
protected function initialize(InputInterface $input, OutputInterface $output)
{
// Allow the hook manager a chance to provide configuration values,
// if there are any registered hooks to do that.
$this->commandProcessor()->initializeHook($input, $this->getNames(), $this->annotationData);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
// Validate, run, process, alter, handle results.
return $this->commandProcessor()->process(
$output,
$this->getNames(),
$this->commandCallback,
$this->createCommandData($input, $output)
);
}
/**
* This function is available for use by a class that may
* wish to extend this class rather than use annotations to
* define commands. Using this technique does allow for the
* use of annotations to define hooks.
*/
public function processResults(InputInterface $input, OutputInterface $output, $results)
{
$commandData = $this->createCommandData($input, $output);
$commandProcessor = $this->commandProcessor();
$names = $this->getNames();
$results = $commandProcessor->processResults(
$names,
$results,
$commandData
);
return $commandProcessor->handleResults(
$output,
$names,
$results,
$commandData
);
}
protected function createCommandData(InputInterface $input, OutputInterface $output)
{
$commandData = new CommandData(
$this->annotationData,
$input,
$output
);
$commandData->setUseIOInterfaces(
$this->usesInputInterface,
$this->usesOutputInterface
);
// Allow the commandData to cache the list of options with
// special default values ('null' and 'true'), as these will
// need special handling. @see CommandData::options().
$commandData->cacheSpecialDefaults($this->getDefinition());
return $commandData;
}
}

View file

@ -0,0 +1,458 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Consolidation\AnnotatedCommand\Cache\CacheWrapper;
use Consolidation\AnnotatedCommand\Cache\NullCache;
use Consolidation\AnnotatedCommand\Cache\SimpleCacheInterface;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Options\AutomaticOptionsProviderInterface;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
use Consolidation\AnnotatedCommand\Parser\CommandInfoDeserializer;
use Consolidation\AnnotatedCommand\Parser\CommandInfoSerializer;
use Consolidation\OutputFormatters\Options\FormatterOptions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* The AnnotatedCommandFactory creates commands for your application.
* Use with a Dependency Injection Container and the CommandFactory.
* Alternately, use the CommandFileDiscovery to find commandfiles, and
* then use AnnotatedCommandFactory::createCommandsFromClass() to create
* commands. See the README for more information.
*
* @package Consolidation\AnnotatedCommand
*/
class AnnotatedCommandFactory implements AutomaticOptionsProviderInterface
{
/** var CommandProcessor */
protected $commandProcessor;
/** var CommandCreationListenerInterface[] */
protected $listeners = [];
/** var AutomaticOptionsProvider[] */
protected $automaticOptionsProviderList = [];
/** var boolean */
protected $includeAllPublicMethods = true;
/** var CommandInfoAltererInterface */
protected $commandInfoAlterers = [];
/** var SimpleCacheInterface */
protected $dataStore;
public function __construct()
{
$this->dataStore = new NullCache();
$this->commandProcessor = new CommandProcessor(new HookManager());
$this->addAutomaticOptionProvider($this);
}
public function setCommandProcessor(CommandProcessor $commandProcessor)
{
$this->commandProcessor = $commandProcessor;
return $this;
}
/**
* @return CommandProcessor
*/
public function commandProcessor()
{
return $this->commandProcessor;
}
/**
* Set the 'include all public methods flag'. If true (the default), then
* every public method of each commandFile will be used to create commands.
* If it is false, then only those public methods annotated with @command
* or @name (deprecated) will be used to create commands.
*/
public function setIncludeAllPublicMethods($includeAllPublicMethods)
{
$this->includeAllPublicMethods = $includeAllPublicMethods;
return $this;
}
public function getIncludeAllPublicMethods()
{
return $this->includeAllPublicMethods;
}
/**
* @return HookManager
*/
public function hookManager()
{
return $this->commandProcessor()->hookManager();
}
/**
* Add a listener that is notified immediately before the command
* factory creates commands from a commandFile instance. This
* listener can use this opportunity to do more setup for the commandFile,
* and so on.
*
* @param CommandCreationListenerInterface $listener
*/
public function addListener(CommandCreationListenerInterface $listener)
{
$this->listeners[] = $listener;
return $this;
}
/**
* Add a listener that's just a simple 'callable'.
* @param callable $listener
*/
public function addListernerCallback(callable $listener)
{
$this->addListener(new CommandCreationListener($listener));
return $this;
}
/**
* Call all command creation listeners
*
* @param object $commandFileInstance
*/
protected function notify($commandFileInstance)
{
foreach ($this->listeners as $listener) {
$listener->notifyCommandFileAdded($commandFileInstance);
}
}
public function addAutomaticOptionProvider(AutomaticOptionsProviderInterface $optionsProvider)
{
$this->automaticOptionsProviderList[] = $optionsProvider;
}
public function addCommandInfoAlterer(CommandInfoAltererInterface $alterer)
{
$this->commandInfoAlterers[] = $alterer;
}
/**
* n.b. This registers all hooks from the commandfile instance as a side-effect.
*/
public function createCommandsFromClass($commandFileInstance, $includeAllPublicMethods = null)
{
// Deprecated: avoid using the $includeAllPublicMethods in favor of the setIncludeAllPublicMethods() accessor.
if (!isset($includeAllPublicMethods)) {
$includeAllPublicMethods = $this->getIncludeAllPublicMethods();
}
$this->notify($commandFileInstance);
$commandInfoList = $this->getCommandInfoListFromClass($commandFileInstance);
$this->registerCommandHooksFromClassInfo($commandInfoList, $commandFileInstance);
return $this->createCommandsFromClassInfo($commandInfoList, $commandFileInstance, $includeAllPublicMethods);
}
public function getCommandInfoListFromClass($commandFileInstance)
{
$cachedCommandInfoList = $this->getCommandInfoListFromCache($commandFileInstance);
$commandInfoList = $this->createCommandInfoListFromClass($commandFileInstance, $cachedCommandInfoList);
if (!empty($commandInfoList)) {
$cachedCommandInfoList = array_merge($commandInfoList, $cachedCommandInfoList);
$this->storeCommandInfoListInCache($commandFileInstance, $cachedCommandInfoList);
}
return $cachedCommandInfoList;
}
protected function storeCommandInfoListInCache($commandFileInstance, $commandInfoList)
{
if (!$this->hasDataStore()) {
return;
}
$cache_data = [];
$serializer = new CommandInfoSerializer();
foreach ($commandInfoList as $i => $commandInfo) {
$cache_data[$i] = $serializer->serialize($commandInfo);
}
$className = get_class($commandFileInstance);
$this->getDataStore()->set($className, $cache_data);
}
/**
* Get the command info list from the cache
*
* @param mixed $commandFileInstance
* @return array
*/
protected function getCommandInfoListFromCache($commandFileInstance)
{
$commandInfoList = [];
$className = get_class($commandFileInstance);
if (!$this->getDataStore()->has($className)) {
return [];
}
$deserializer = new CommandInfoDeserializer();
$cache_data = $this->getDataStore()->get($className);
foreach ($cache_data as $i => $data) {
if (CommandInfoDeserializer::isValidSerializedData((array)$data)) {
$commandInfoList[$i] = $deserializer->deserialize((array)$data);
}
}
return $commandInfoList;
}
/**
* Check to see if this factory has a cache datastore.
* @return boolean
*/
public function hasDataStore()
{
return !($this->dataStore instanceof NullCache);
}
/**
* Set a cache datastore for this factory. Any object with 'set' and
* 'get' methods is acceptable. The key is the classname being cached,
* and the value is a nested associative array of strings.
*
* TODO: Typehint this to SimpleCacheInterface
*
* This is not done currently to allow clients to use a generic cache
* store that does not itself depend on the annotated-command library.
*
* @param Mixed $dataStore
* @return type
*/
public function setDataStore($dataStore)
{
if (!($dataStore instanceof SimpleCacheInterface)) {
$dataStore = new CacheWrapper($dataStore);
}
$this->dataStore = $dataStore;
return $this;
}
/**
* Get the data store attached to this factory.
*/
public function getDataStore()
{
return $this->dataStore;
}
protected function createCommandInfoListFromClass($classNameOrInstance, $cachedCommandInfoList)
{
$commandInfoList = [];
// Ignore special functions, such as __construct and __call, which
// can never be commands.
$commandMethodNames = array_filter(
get_class_methods($classNameOrInstance) ?: [],
function ($m) use ($classNameOrInstance) {
$reflectionMethod = new \ReflectionMethod($classNameOrInstance, $m);
return !$reflectionMethod->isStatic() && !preg_match('#^_#', $m);
}
);
foreach ($commandMethodNames as $commandMethodName) {
if (!array_key_exists($commandMethodName, $cachedCommandInfoList)) {
$commandInfo = CommandInfo::create($classNameOrInstance, $commandMethodName);
if (!static::isCommandOrHookMethod($commandInfo, $this->getIncludeAllPublicMethods())) {
$commandInfo->invalidate();
}
$commandInfoList[$commandMethodName] = $commandInfo;
}
}
return $commandInfoList;
}
public function createCommandInfo($classNameOrInstance, $commandMethodName)
{
return CommandInfo::create($classNameOrInstance, $commandMethodName);
}
public function createCommandsFromClassInfo($commandInfoList, $commandFileInstance, $includeAllPublicMethods = null)
{
// Deprecated: avoid using the $includeAllPublicMethods in favor of the setIncludeAllPublicMethods() accessor.
if (!isset($includeAllPublicMethods)) {
$includeAllPublicMethods = $this->getIncludeAllPublicMethods();
}
return $this->createSelectedCommandsFromClassInfo(
$commandInfoList,
$commandFileInstance,
function ($commandInfo) use ($includeAllPublicMethods) {
return static::isCommandMethod($commandInfo, $includeAllPublicMethods);
}
);
}
public function createSelectedCommandsFromClassInfo($commandInfoList, $commandFileInstance, callable $commandSelector)
{
$commandInfoList = $this->filterCommandInfoList($commandInfoList, $commandSelector);
return array_map(
function ($commandInfo) use ($commandFileInstance) {
return $this->createCommand($commandInfo, $commandFileInstance);
},
$commandInfoList
);
}
protected function filterCommandInfoList($commandInfoList, callable $commandSelector)
{
return array_filter($commandInfoList, $commandSelector);
}
public static function isCommandOrHookMethod($commandInfo, $includeAllPublicMethods)
{
return static::isHookMethod($commandInfo) || static::isCommandMethod($commandInfo, $includeAllPublicMethods);
}
public static function isHookMethod($commandInfo)
{
return $commandInfo->hasAnnotation('hook');
}
public static function isCommandMethod($commandInfo, $includeAllPublicMethods)
{
// Ignore everything labeled @hook
if (static::isHookMethod($commandInfo)) {
return false;
}
// Include everything labeled @command
if ($commandInfo->hasAnnotation('command')) {
return true;
}
// Skip anything that has a missing or invalid name.
$commandName = $commandInfo->getName();
if (empty($commandName) || preg_match('#[^a-zA-Z0-9:_-]#', $commandName)) {
return false;
}
// Skip anything named like an accessor ('get' or 'set')
if (preg_match('#^(get[A-Z]|set[A-Z])#', $commandInfo->getMethodName())) {
return false;
}
// Default to the setting of 'include all public methods'.
return $includeAllPublicMethods;
}
public function registerCommandHooksFromClassInfo($commandInfoList, $commandFileInstance)
{
foreach ($commandInfoList as $commandInfo) {
if (static::isHookMethod($commandInfo)) {
$this->registerCommandHook($commandInfo, $commandFileInstance);
}
}
}
/**
* Register a command hook given the CommandInfo for a method.
*
* The hook format is:
*
* @hook type name type
*
* For example, the pre-validate hook for the core:init command is:
*
* @hook pre-validate core:init
*
* If no command name is provided, then this hook will affect every
* command that is defined in the same file.
*
* If no hook is provided, then we will presume that ALTER_RESULT
* is intended.
*
* @param CommandInfo $commandInfo Information about the command hook method.
* @param object $commandFileInstance An instance of the CommandFile class.
*/
public function registerCommandHook(CommandInfo $commandInfo, $commandFileInstance)
{
// Ignore if the command info has no @hook
if (!static::isHookMethod($commandInfo)) {
return;
}
$hookData = $commandInfo->getAnnotation('hook');
$hook = $this->getNthWord($hookData, 0, HookManager::ALTER_RESULT);
$commandName = $this->getNthWord($hookData, 1);
// Register the hook
$callback = [$commandFileInstance, $commandInfo->getMethodName()];
$this->commandProcessor()->hookManager()->add($callback, $hook, $commandName);
// If the hook has options, then also register the commandInfo
// with the hook manager, so that we can add options and such to
// the commands they hook.
if (!$commandInfo->options()->isEmpty()) {
$this->commandProcessor()->hookManager()->recordHookOptions($commandInfo, $commandName);
}
}
protected function getNthWord($string, $n, $default = '', $delimiter = ' ')
{
$words = explode($delimiter, $string);
if (!empty($words[$n])) {
return $words[$n];
}
return $default;
}
public function createCommand(CommandInfo $commandInfo, $commandFileInstance)
{
$this->alterCommandInfo($commandInfo, $commandFileInstance);
$command = new AnnotatedCommand($commandInfo->getName());
$commandCallback = [$commandFileInstance, $commandInfo->getMethodName()];
$command->setCommandCallback($commandCallback);
$command->setCommandProcessor($this->commandProcessor);
$command->setCommandInfo($commandInfo);
$automaticOptions = $this->callAutomaticOptionsProviders($commandInfo);
$command->setCommandOptions($commandInfo, $automaticOptions);
// Annotation commands are never bootstrap-aware, but for completeness
// we will notify on every created command, as some clients may wish to
// use this notification for some other purpose.
$this->notify($command);
return $command;
}
/**
* Give plugins an opportunity to update the commandInfo
*/
public function alterCommandInfo(CommandInfo $commandInfo, $commandFileInstance)
{
foreach ($this->commandInfoAlterers as $alterer) {
$alterer->alterCommandInfo($commandInfo, $commandFileInstance);
}
}
/**
* Get the options that are implied by annotations, e.g. @fields implies
* that there should be a --fields and a --format option.
*
* @return InputOption[]
*/
public function callAutomaticOptionsProviders(CommandInfo $commandInfo)
{
$automaticOptions = [];
foreach ($this->automaticOptionsProviderList as $automaticOptionsProvider) {
$automaticOptions += $automaticOptionsProvider->automaticOptions($commandInfo);
}
return $automaticOptions;
}
/**
* Get the options that are implied by annotations, e.g. @fields implies
* that there should be a --fields and a --format option.
*
* @return InputOption[]
*/
public function automaticOptions(CommandInfo $commandInfo)
{
$automaticOptions = [];
$formatManager = $this->commandProcessor()->formatterManager();
if ($formatManager) {
$annotationData = $commandInfo->getAnnotations()->getArrayCopy();
$formatterOptions = new FormatterOptions($annotationData);
$dataType = $commandInfo->getReturnType();
$automaticOptions = $formatManager->automaticOptions($formatterOptions, $dataType);
}
return $automaticOptions;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Consolidation\AnnotatedCommand\Parser\Internal\CsvUtils;
class AnnotationData extends \ArrayObject
{
public function get($key, $default = '')
{
return $this->has($key) ? CsvUtils::toString($this[$key]) : $default;
}
public function getList($key, $default = [])
{
return $this->has($key) ? CsvUtils::toList($this[$key]) : $default;
}
public function has($key)
{
return isset($this[$key]);
}
public function keys()
{
return array_keys($this->getArrayCopy());
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Consolidation\AnnotatedCommand\Cache;
/**
* Make a generic cache object conform to our expected interface.
*/
class CacheWrapper implements SimpleCacheInterface
{
protected $dataStore;
public function __construct($dataStore)
{
$this->dataStore = $dataStore;
}
/**
* Test for an entry from the cache
* @param string $key
* @return boolean
*/
public function has($key)
{
if (method_exists($this->dataStore, 'has')) {
return $this->dataStore->has($key);
}
$test = $this->dataStore->get($key);
return !empty($test);
}
/**
* Get an entry from the cache
* @param string $key
* @return array
*/
public function get($key)
{
return (array) $this->dataStore->get($key);
}
/**
* Store an entry in the cache
* @param string $key
* @param array $data
*/
public function set($key, $data)
{
$this->dataStore->set($key, $data);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Consolidation\AnnotatedCommand\Cache;
/**
* An empty cache that never stores or fetches any objects.
*/
class NullCache implements SimpleCacheInterface
{
/**
* Test for an entry from the cache
* @param string $key
* @return boolean
*/
public function has($key)
{
return false;
}
/**
* Get an entry from the cache
* @param string $key
* @return array
*/
public function get($key)
{
return [];
}
/**
* Store an entry in the cache
* @param string $key
* @param array $data
*/
public function set($key, $data)
{
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Consolidation\AnnotatedCommand\Cache;
/**
* Documentation interface.
*
* Clients that use AnnotatedCommandFactory::setDataStore()
* are encouraged to provide a data store that implements
* this interface.
*
* This is not currently required to allow clients to use a generic cache
* store that does not itself depend on the annotated-command library.
* This might be required in a future version.
*/
interface SimpleCacheInterface
{
/**
* Test for an entry from the cache
* @param string $key
* @return boolean
*/
public function has($key);
/**
* Get an entry from the cache
* @param string $key
* @return array
*/
public function get($key);
/**
* Store an entry in the cache
* @param string $key
* @param array $data
*/
public function set($key, $data);
}

View file

@ -0,0 +1,25 @@
<?php
namespace Consolidation\AnnotatedCommand;
/**
* Command cration listeners can be added to the annotation
* command factory. These will be notified whenever a new
* commandfile is provided to the factory. This is useful for
* initializing new commandfile objects.
*
* @see AnnotatedCommandFactory::addListener()
*/
class CommandCreationListener implements CommandCreationListenerInterface
{
protected $listener;
public function __construct($listener)
{
$this->listener = $listener;
}
public function notifyCommandFileAdded($command)
{
call_user_func($this->listener, $command);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Consolidation\AnnotatedCommand;
/**
* Command cration listeners can be added to the annotation
* command factory. These will be notified whenever a new
* commandfile is provided to the factory. This is useful for
* initializing new commandfile objects.
*
* @see AnnotatedCommandFactory::addListener()
*/
interface CommandCreationListenerInterface
{
public function notifyCommandFileAdded($command);
}

View file

@ -0,0 +1,195 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class CommandData
{
/** var AnnotationData */
protected $annotationData;
/** var InputInterface */
protected $input;
/** var OutputInterface */
protected $output;
/** var boolean */
protected $usesInputInterface;
/** var boolean */
protected $usesOutputInterface;
/** var boolean */
protected $includeOptionsInArgs;
/** var array */
protected $specialDefaults = [];
public function __construct(
AnnotationData $annotationData,
InputInterface $input,
OutputInterface $output,
$usesInputInterface = false,
$usesOutputInterface = false
) {
$this->annotationData = $annotationData;
$this->input = $input;
$this->output = $output;
$this->usesInputInterface = false;
$this->usesOutputInterface = false;
$this->includeOptionsInArgs = true;
}
/**
* For internal use only; indicates that the function to be called
* should be passed an InputInterface &/or an OutputInterface.
* @param booean $usesInputInterface
* @param boolean $usesOutputInterface
* @return self
*/
public function setUseIOInterfaces($usesInputInterface, $usesOutputInterface)
{
$this->usesInputInterface = $usesInputInterface;
$this->usesOutputInterface = $usesOutputInterface;
return $this;
}
/**
* For backwards-compatibility mode only: disable addition of
* options on the end of the arguments list.
*/
public function setIncludeOptionsInArgs($includeOptionsInArgs)
{
$this->includeOptionsInArgs = $includeOptionsInArgs;
return $this;
}
public function annotationData()
{
return $this->annotationData;
}
public function input()
{
return $this->input;
}
public function output()
{
return $this->output;
}
public function arguments()
{
return $this->input->getArguments();
}
public function options()
{
// We cannot tell the difference between '--foo' (an option without
// a value) and the absence of '--foo' when the option has an optional
// value, and the current vallue of the option is 'null' using only
// the public methods of InputInterface. We'll try to figure out
// which is which by other means here.
$options = $this->getAdjustedOptions();
// Make two conversions here:
// --foo=0 wil convert $value from '0' to 'false' for binary options.
// --foo with $value of 'true' will be forced to 'false' if --no-foo exists.
foreach ($options as $option => $value) {
if ($this->shouldConvertOptionToFalse($options, $option, $value)) {
$options[$option] = false;
}
}
return $options;
}
/**
* Use 'hasParameterOption()' to attempt to disambiguate option states.
*/
protected function getAdjustedOptions()
{
$options = $this->input->getOptions();
// If Input isn't an ArgvInput, then return the options as-is.
if (!$this->input instanceof ArgvInput) {
return $options;
}
// If we have an ArgvInput, then we can determine if options
// are missing from the command line. If the option value is
// missing from $input, then we will keep the value `null`.
// If it is present, but has no explicit value, then change it its
// value to `true`.
foreach ($options as $option => $value) {
if (($value === null) && ($this->input->hasParameterOption("--$option"))) {
$options[$option] = true;
}
}
return $options;
}
protected function shouldConvertOptionToFalse($options, $option, $value)
{
// If the value is 'true' (e.g. the option is '--foo'), then convert
// it to false if there is also an option '--no-foo'. n.b. if the
// commandline has '--foo=bar' then $value will not be 'true', and
// --no-foo will be ignored.
if ($value === true) {
// Check if the --no-* option exists. Note that none of the other
// alteration apply in the $value == true case, so we can exit early here.
$negation_key = 'no-' . $option;
return array_key_exists($negation_key, $options) && $options[$negation_key];
}
// If the option is '--foo=0', convert the '0' to 'false' when appropriate.
if ($value !== '0') {
return false;
}
// The '--foo=0' convertion is only applicable when the default value
// is not in the special defaults list. i.e. you get a literal '0'
// when your default is a string.
return in_array($option, $this->specialDefaults);
}
public function cacheSpecialDefaults($definition)
{
foreach ($definition->getOptions() as $option => $inputOption) {
$defaultValue = $inputOption->getDefault();
if (($defaultValue === null) || ($defaultValue === true)) {
$this->specialDefaults[] = $option;
}
}
}
public function getArgsWithoutAppName()
{
$args = $this->arguments();
// When called via the Application, the first argument
// will be the command name. The Application alters the
// input definition to match, adding a 'command' argument
// to the beginning.
array_shift($args);
if ($this->usesOutputInterface) {
array_unshift($args, $this->output());
}
if ($this->usesInputInterface) {
array_unshift($args, $this->input());
}
return $args;
}
public function getArgsAndOptions()
{
// Get passthrough args, and add the options on the end.
$args = $this->getArgsWithoutAppName();
if ($this->includeOptionsInArgs) {
$args['options'] = $this->options();
}
return $args;
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Consolidation\AnnotatedCommand;
/**
* Return a CommandError as the result of a command to pass a status
* code and error message to be displayed.
*
* @package Consolidation\AnnotatedCommand
*/
class CommandError implements ExitCodeInterface, OutputDataInterface
{
protected $message;
protected $exitCode;
public function __construct($message = null, $exitCode = 1)
{
$this->message = $message;
// Ensure the exit code is non-zero. The exit code may have
// come from an exception, and those often default to zero if
// a specific value is not provided.
$this->exitCode = $exitCode == 0 ? 1 : $exitCode;
}
public function getExitCode()
{
return $this->exitCode;
}
public function getOutputData()
{
return $this->message;
}
}

View file

@ -0,0 +1,406 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Symfony\Component\Finder\Finder;
/**
* Do discovery presuming that the namespace of the command will
* contain the last component of the path. This is the convention
* that should be used when searching for command files that are
* bundled with the modules of a framework. The convention used
* is that the namespace for a module in a framework should start with
* the framework name followed by the module name.
*
* For example, if base namespace is "Drupal", then a command file in
* modules/default_content/src/CliTools/ExampleCommands.cpp
* will be in the namespace Drupal\default_content\CliTools.
*
* For global locations, the middle component of the namespace is
* omitted. For example, if the base namespace is "Drupal", then
* a command file in __DRUPAL_ROOT__/CliTools/ExampleCommands.cpp
* will be in the namespace Drupal\CliTools.
*
* To discover namespaced commands in modules:
*
* $commandFiles = $discovery->discoverNamespaced($moduleList, '\Drupal');
*
* To discover global commands:
*
* $commandFiles = $discovery->discover($drupalRoot, '\Drupal');
*/
class CommandFileDiscovery
{
/** @var string[] */
protected $excludeList;
/** @var string[] */
protected $searchLocations;
/** @var string */
protected $searchPattern = '*Commands.php';
/** @var boolean */
protected $includeFilesAtBase = true;
/** @var integer */
protected $searchDepth = 2;
/** @var bool */
protected $followLinks = false;
public function __construct()
{
$this->excludeList = ['Exclude'];
$this->searchLocations = [
'Command',
'CliTools', // TODO: Maybe remove
];
}
/**
* Specify whether to search for files at the base directory
* ($directoryList parameter to discover and discoverNamespaced
* methods), or only in the directories listed in the search paths.
*
* @param boolean $includeFilesAtBase
*/
public function setIncludeFilesAtBase($includeFilesAtBase)
{
$this->includeFilesAtBase = $includeFilesAtBase;
return $this;
}
/**
* Set the list of excludes to add to the finder, replacing
* whatever was there before.
*
* @param array $excludeList The list of directory names to skip when
* searching for command files.
*/
public function setExcludeList($excludeList)
{
$this->excludeList = $excludeList;
return $this;
}
/**
* Add one more location to the exclude list.
*
* @param string $exclude One directory name to skip when searching
* for command files.
*/
public function addExclude($exclude)
{
$this->excludeList[] = $exclude;
return $this;
}
/**
* Set the search depth. By default, fills immediately in the
* base directory are searched, plus all of the search locations
* to this specified depth. If the search locations is set to
* an empty array, then the base directory is searched to this
* depth.
*/
public function setSearchDepth($searchDepth)
{
$this->searchDepth = $searchDepth;
return $this;
}
/**
* Specify that the discovery object should follow symlinks. By
* default, symlinks are not followed.
*/
public function followLinks($followLinks = true)
{
$this->followLinks = $followLinks;
return $this;
}
/**
* Set the list of search locations to examine in each directory where
* command files may be found. This replaces whatever was there before.
*
* @param array $searchLocations The list of locations to search for command files.
*/
public function setSearchLocations($searchLocations)
{
$this->searchLocations = $searchLocations;
return $this;
}
/**
* Add one more location to the search location list.
*
* @param string $location One more relative path to search
* for command files.
*/
public function addSearchLocation($location)
{
$this->searchLocations[] = $location;
return $this;
}
/**
* Specify the pattern / regex used by the finder to search for
* command files.
*/
public function setSearchPattern($searchPattern)
{
$this->searchPattern = $searchPattern;
return $this;
}
/**
* Given a list of directories, e.g. Drupal modules like:
*
* core/modules/block
* core/modules/dblog
* modules/default_content
*
* Discover command files in any of these locations.
*
* @param string|string[] $directoryList Places to search for commands.
*
* @return array
*/
public function discoverNamespaced($directoryList, $baseNamespace = '')
{
return $this->discover($this->convertToNamespacedList((array)$directoryList), $baseNamespace);
}
/**
* Given a simple list containing paths to directories, where
* the last component of the path should appear in the namespace,
* after the base namespace, this function will return an
* associative array mapping the path's basename (e.g. the module
* name) to the directory path.
*
* Module names must be unique.
*
* @param string[] $directoryList A list of module locations
*
* @return array
*/
public function convertToNamespacedList($directoryList)
{
$namespacedArray = [];
foreach ((array)$directoryList as $directory) {
$namespacedArray[basename($directory)] = $directory;
}
return $namespacedArray;
}
/**
* Search for command files in the specified locations. This is the function that
* should be used for all locations that are NOT modules of a framework.
*
* @param string|string[] $directoryList Places to search for commands.
* @return array
*/
public function discover($directoryList, $baseNamespace = '')
{
$commandFiles = [];
foreach ((array)$directoryList as $key => $directory) {
$itemsNamespace = $this->joinNamespace([$baseNamespace, $key]);
$commandFiles = array_merge(
$commandFiles,
$this->discoverCommandFiles($directory, $itemsNamespace),
$this->discoverCommandFiles("$directory/src", $itemsNamespace)
);
}
return $commandFiles;
}
/**
* Search for command files in specific locations within a single directory.
*
* In each location, we will accept only a few places where command files
* can be found. This will reduce the need to search through many unrelated
* files.
*
* The default search locations include:
*
* .
* CliTools
* src/CliTools
*
* The pattern we will look for is any file whose name ends in 'Commands.php'.
* A list of paths to found files will be returned.
*/
protected function discoverCommandFiles($directory, $baseNamespace)
{
$commandFiles = [];
// In the search location itself, we will search for command files
// immediately inside the directory only.
if ($this->includeFilesAtBase) {
$commandFiles = $this->discoverCommandFilesInLocation(
$directory,
$this->getBaseDirectorySearchDepth(),
$baseNamespace
);
}
// In the other search locations,
foreach ($this->searchLocations as $location) {
$itemsNamespace = $this->joinNamespace([$baseNamespace, $location]);
$commandFiles = array_merge(
$commandFiles,
$this->discoverCommandFilesInLocation(
"$directory/$location",
$this->getSearchDepth(),
$itemsNamespace
)
);
}
return $commandFiles;
}
/**
* Return a Finder search depth appropriate for our selected search depth.
*
* @return string
*/
protected function getSearchDepth()
{
return $this->searchDepth <= 0 ? '== 0' : '<= ' . $this->searchDepth;
}
/**
* Return a Finder search depth for the base directory. If the
* searchLocations array has been populated, then we will only search
* for files immediately inside the base directory; no traversal into
* deeper directories will be done, as that would conflict with the
* specification provided by the search locations. If there is no
* search location, then we will search to whatever depth was specified
* by the client.
*
* @return string
*/
protected function getBaseDirectorySearchDepth()
{
if (!empty($this->searchLocations)) {
return '== 0';
}
return $this->getSearchDepth();
}
/**
* Search for command files in just one particular location. Returns
* an associative array mapping from the pathname of the file to the
* classname that it contains. The pathname may be ignored if the search
* location is included in the autoloader.
*
* @param string $directory The location to search
* @param string $depth How deep to search (e.g. '== 0' or '< 2')
* @param string $baseNamespace Namespace to prepend to each classname
*
* @return array
*/
protected function discoverCommandFilesInLocation($directory, $depth, $baseNamespace)
{
if (!is_dir($directory)) {
return [];
}
$finder = $this->createFinder($directory, $depth);
$commands = [];
foreach ($finder as $file) {
$relativePathName = $file->getRelativePathname();
$relativeNamespaceAndClassname = str_replace(
['/', '.php'],
['\\', ''],
$relativePathName
);
$classname = $this->joinNamespace([$baseNamespace, $relativeNamespaceAndClassname]);
$commandFilePath = $this->joinPaths([$directory, $relativePathName]);
$commands[$commandFilePath] = $classname;
}
return $commands;
}
/**
* Create a Finder object for use in searching a particular directory
* location.
*
* @param string $directory The location to search
* @param string $depth The depth limitation
*
* @return Finder
*/
protected function createFinder($directory, $depth)
{
$finder = new Finder();
$finder->files()
->name($this->searchPattern)
->in($directory)
->depth($depth);
foreach ($this->excludeList as $item) {
$finder->exclude($item);
}
if ($this->followLinks) {
$finder->followLinks();
}
return $finder;
}
/**
* Combine the items of the provied array into a backslash-separated
* namespace string. Empty and numeric items are omitted.
*
* @param array $namespaceParts List of components of a namespace
*
* @return string
*/
protected function joinNamespace(array $namespaceParts)
{
return $this->joinParts(
'\\',
$namespaceParts,
function ($item) {
return !is_numeric($item) && !empty($item);
}
);
}
/**
* Combine the items of the provied array into a slash-separated
* pathname. Empty items are omitted.
*
* @param array $pathParts List of components of a path
*
* @return string
*/
protected function joinPaths(array $pathParts)
{
$path = $this->joinParts(
'/',
$pathParts,
function ($item) {
return !empty($item);
}
);
return str_replace(DIRECTORY_SEPARATOR, '/', $path);
}
/**
* Simple wrapper around implode and array_filter.
*
* @param string $delimiter
* @param array $parts
* @param callable $filterFunction
*/
protected function joinParts($delimiter, $parts, $filterFunction)
{
$parts = array_map(
function ($item) use ($delimiter) {
return rtrim($item, $delimiter);
},
$parts
);
return implode(
$delimiter,
array_filter($parts, $filterFunction)
);
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
interface CommandInfoAltererInterface
{
public function alterCommandInfo(CommandInfo $commandInfo, $commandFileInstance);
}

View file

@ -0,0 +1,363 @@
<?php
namespace Consolidation\AnnotatedCommand;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\ReplaceCommandHookDispatcher;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Consolidation\OutputFormatters\FormatterManager;
use Consolidation\OutputFormatters\Options\FormatterOptions;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Options\PrepareFormatter;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\InitializeHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\OptionsHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\InteractHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\ValidateHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\ProcessResultHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\StatusDeterminerHookDispatcher;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\ExtracterHookDispatcher;
/**
* Process a command, including hooks and other callbacks.
* There should only be one command processor per application.
* Provide your command processor to the AnnotatedCommandFactory
* via AnnotatedCommandFactory::setCommandProcessor().
*/
class CommandProcessor implements LoggerAwareInterface
{
use LoggerAwareTrait;
/** var HookManager */
protected $hookManager;
/** var FormatterManager */
protected $formatterManager;
/** var callable */
protected $displayErrorFunction;
/** var PrepareFormatterOptions[] */
protected $prepareOptionsList = [];
/** var boolean */
protected $passExceptions;
public function __construct(HookManager $hookManager)
{
$this->hookManager = $hookManager;
}
/**
* Return the hook manager
* @return HookManager
*/
public function hookManager()
{
return $this->hookManager;
}
public function addPrepareFormatter(PrepareFormatter $preparer)
{
$this->prepareOptionsList[] = $preparer;
}
public function setFormatterManager(FormatterManager $formatterManager)
{
$this->formatterManager = $formatterManager;
return $this;
}
public function setDisplayErrorFunction(callable $fn)
{
$this->displayErrorFunction = $fn;
return $this;
}
/**
* Set a mode to make the annotated command library re-throw
* any exception that it catches while processing a command.
*
* The default behavior in the current (2.x) branch is to catch
* the exception and replace it with a CommandError object that
* may be processed by the normal output processing passthrough.
*
* In the 3.x branch, exceptions will never be caught; they will
* be passed through, as if setPassExceptions(true) were called.
* This is the recommended behavior.
*/
public function setPassExceptions($passExceptions)
{
$this->passExceptions = $passExceptions;
return $this;
}
public function commandErrorForException(\Exception $e)
{
if ($this->passExceptions) {
throw $e;
}
return new CommandError($e->getMessage(), $e->getCode());
}
/**
* Return the formatter manager
* @return FormatterManager
*/
public function formatterManager()
{
return $this->formatterManager;
}
public function initializeHook(
InputInterface $input,
$names,
AnnotationData $annotationData
) {
$initializeDispatcher = new InitializeHookDispatcher($this->hookManager(), $names);
return $initializeDispatcher->initialize($input, $annotationData);
}
public function optionsHook(
AnnotatedCommand $command,
$names,
AnnotationData $annotationData
) {
$optionsDispatcher = new OptionsHookDispatcher($this->hookManager(), $names);
$optionsDispatcher->getOptions($command, $annotationData);
}
public function interact(
InputInterface $input,
OutputInterface $output,
$names,
AnnotationData $annotationData
) {
$interactDispatcher = new InteractHookDispatcher($this->hookManager(), $names);
return $interactDispatcher->interact($input, $output, $annotationData);
}
public function process(
OutputInterface $output,
$names,
$commandCallback,
CommandData $commandData
) {
$result = [];
try {
$result = $this->validateRunAndAlter(
$names,
$commandCallback,
$commandData
);
return $this->handleResults($output, $names, $result, $commandData);
} catch (\Exception $e) {
$result = $this->commandErrorForException($e);
return $this->handleResults($output, $names, $result, $commandData);
}
}
public function validateRunAndAlter(
$names,
$commandCallback,
CommandData $commandData
) {
// Validators return any object to signal a validation error;
// if the return an array, it replaces the arguments.
$validateDispatcher = new ValidateHookDispatcher($this->hookManager(), $names);
$validated = $validateDispatcher->validate($commandData);
if (is_object($validated)) {
return $validated;
}
$replaceDispatcher = new ReplaceCommandHookDispatcher($this->hookManager(), $names);
if ($this->logger) {
$replaceDispatcher->setLogger($this->logger);
}
if ($replaceDispatcher->hasReplaceCommandHook()) {
$commandCallback = $replaceDispatcher->getReplacementCommand($commandData);
}
// Run the command, alter the results, and then handle output and status
$result = $this->runCommandCallback($commandCallback, $commandData);
return $this->processResults($names, $result, $commandData);
}
public function processResults($names, $result, CommandData $commandData)
{
$processDispatcher = new ProcessResultHookDispatcher($this->hookManager(), $names);
return $processDispatcher->process($result, $commandData);
}
/**
* Handle the result output and status code calculation.
*/
public function handleResults(OutputInterface $output, $names, $result, CommandData $commandData)
{
$statusCodeDispatcher = new StatusDeterminerHookDispatcher($this->hookManager(), $names);
$status = $statusCodeDispatcher->determineStatusCode($result);
// If the result is an integer and no separate status code was provided, then use the result as the status and do no output.
if (is_integer($result) && !isset($status)) {
return $result;
}
$status = $this->interpretStatusCode($status);
// Get the structured output, the output stream and the formatter
$extractDispatcher = new ExtracterHookDispatcher($this->hookManager(), $names);
$structuredOutput = $extractDispatcher->extractOutput($result);
$output = $this->chooseOutputStream($output, $status);
if ($status != 0) {
return $this->writeErrorMessage($output, $status, $structuredOutput, $result);
}
if ($this->dataCanBeFormatted($structuredOutput) && isset($this->formatterManager)) {
return $this->writeUsingFormatter($output, $structuredOutput, $commandData);
}
return $this->writeCommandOutput($output, $structuredOutput);
}
protected function dataCanBeFormatted($structuredOutput)
{
if (!isset($this->formatterManager)) {
return false;
}
return
is_object($structuredOutput) ||
is_array($structuredOutput);
}
/**
* Run the main command callback
*/
protected function runCommandCallback($commandCallback, CommandData $commandData)
{
$result = false;
try {
$args = $commandData->getArgsAndOptions();
$result = call_user_func_array($commandCallback, $args);
} catch (\Exception $e) {
$result = $this->commandErrorForException($e);
}
return $result;
}
/**
* Determine the formatter that should be used to render
* output.
*
* If the user specified a format via the --format option,
* then always return that. Otherwise, return the default
* format, unless --pipe was specified, in which case
* return the default pipe format, format-pipe.
*
* n.b. --pipe is a handy option introduced in Drush 2
* (or perhaps even Drush 1) that indicates that the command
* should select the output format that is most appropriate
* for use in scripts (e.g. to pipe to another command).
*
* @return string
*/
protected function getFormat(FormatterOptions $options)
{
// In Symfony Console, there is no way for us to differentiate
// between the user specifying '--format=table', and the user
// not specifying --format when the default value is 'table'.
// Therefore, we must make --field always override --format; it
// cannot become the default value for --format.
if ($options->get('field')) {
return 'string';
}
$defaults = [];
if ($options->get('pipe')) {
return $options->get('pipe-format', [], 'tsv');
}
return $options->getFormat($defaults);
}
/**
* Determine whether we should use stdout or stderr.
*/
protected function chooseOutputStream(OutputInterface $output, $status)
{
// If the status code indicates an error, then print the
// result to stderr rather than stdout
if ($status && ($output instanceof ConsoleOutputInterface)) {
return $output->getErrorOutput();
}
return $output;
}
/**
* Call the formatter to output the provided data.
*/
protected function writeUsingFormatter(OutputInterface $output, $structuredOutput, CommandData $commandData)
{
$formatterOptions = $this->createFormatterOptions($commandData);
$format = $this->getFormat($formatterOptions);
$this->formatterManager->write(
$output,
$format,
$structuredOutput,
$formatterOptions
);
return 0;
}
/**
* Create a FormatterOptions object for use in writing the formatted output.
* @param CommandData $commandData
* @return FormatterOptions
*/
protected function createFormatterOptions($commandData)
{
$options = $commandData->input()->getOptions();
$formatterOptions = new FormatterOptions($commandData->annotationData()->getArrayCopy(), $options);
foreach ($this->prepareOptionsList as $preparer) {
$preparer->prepare($commandData, $formatterOptions);
}
return $formatterOptions;
}
/**
* Description
* @param OutputInterface $output
* @param int $status
* @param string $structuredOutput
* @param mixed $originalResult
* @return type
*/
protected function writeErrorMessage($output, $status, $structuredOutput, $originalResult)
{
if (isset($this->displayErrorFunction)) {
call_user_func($this->displayErrorFunction, $output, $structuredOutput, $status, $originalResult);
} else {
$this->writeCommandOutput($output, $structuredOutput);
}
return $status;
}
/**
* If the result object is a string, then print it.
*/
protected function writeCommandOutput(
OutputInterface $output,
$structuredOutput
) {
// If there is no formatter, we will print strings,
// but can do no more than that.
if (is_string($structuredOutput)) {
$output->writeln($structuredOutput);
}
return 0;
}
/**
* If a status code was set, then return it; otherwise,
* presume success.
*/
protected function interpretStatusCode($status)
{
if (isset($status)) {
return $status;
}
return 0;
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Consolidation\AnnotatedCommand\Events;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
interface CustomEventAwareInterface
{
/**
* Set a reference to the hook manager for later use
* @param HookManager $hookManager
*/
public function setHookManager(HookManager $hookManager);
/**
* Get all of the defined event handlers of the specified name.
* @param string $eventName
* @return Callable[]
*/
public function getCustomEventHandlers($eventName);
}

View file

@ -0,0 +1,29 @@
<?php
namespace Consolidation\AnnotatedCommand\Events;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
trait CustomEventAwareTrait
{
/** var HookManager */
protected $hookManager;
/**
* {@inheritdoc}
*/
public function setHookManager(HookManager $hookManager)
{
$this->hookManager = $hookManager;
}
/**
* {@inheritdoc}
*/
public function getCustomEventHandlers($eventName)
{
if (!$this->hookManager) {
return [];
}
return $this->hookManager->getHook($eventName, HookManager::ON_EVENT);
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Consolidation\AnnotatedCommand;
/**
* If an annotated command method encounters an error, then it
* should either throw an exception, or return a result object
* that implements ExitCodeInterface.
*/
interface ExitCodeInterface
{
public function getExitCode();
}

View file

@ -0,0 +1,48 @@
<?php
namespace Consolidation\AnnotatedCommand\Help;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Descriptor\XmlDescriptor;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class HelpCommand
{
/** var Application */
protected $application;
/**
* Create a help document from a Symfony Console command
*/
public function __construct(Application $application)
{
$this->application = $application;
}
public function getApplication()
{
return $this->application;
}
/**
* Run the help command
*
* @command my-help
* @return \Consolidation\AnnotatedCommand\Help\HelpDocument
*/
public function help($commandName = 'help')
{
$command = $this->getApplication()->find($commandName);
$helpDocument = $this->getHelpDocument($command);
return $helpDocument;
}
/**
* Create a help document.
*/
protected function getHelpDocument($command)
{
return new HelpDocument($command);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace Consolidation\AnnotatedCommand\Help;
use Consolidation\OutputFormatters\StructuredData\Xml\DomDataInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Descriptor\XmlDescriptor;
class HelpDocument implements DomDataInterface
{
/** var Command */
protected $command;
/** var \DOMDocument */
protected $dom;
/**
* Create a help document from a Symfony Console command
*/
public function __construct(Command $command)
{
$dom = $this->generateBaseHelpDom($command);
$dom = $this->alterHelpDocument($command, $dom);
$this->command = $command;
$this->dom = $dom;
}
/**
* Convert data into a \DomDocument.
*
* @return \DomDocument
*/
public function getDomData()
{
return $this->dom;
}
/**
* Create the base help DOM prior to alteration by the Command object.
* @param Command $command
* @return \DomDocument
*/
protected function generateBaseHelpDom(Command $command)
{
// Use Symfony to generate xml text. If other formats are
// requested, convert from xml to the desired form.
$descriptor = new XmlDescriptor();
return $descriptor->getCommandDocument($command);
}
/**
* Alter the DOM document per the command object
* @param Command $command
* @param \DomDocument $dom
* @return \DomDocument
*/
protected function alterHelpDocument(Command $command, \DomDocument $dom)
{
if ($command instanceof HelpDocumentAlter) {
$dom = $command->helpAlter($dom);
}
return $dom;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Consolidation\AnnotatedCommand\Help;
interface HelpDocumentAlter
{
public function helpAlter(\DomDocument $dom);
}

View file

@ -0,0 +1,12 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
/**
* Alter the result of a command after it has been processed.
* An alter result interface isa process result interface.
*
* @see HookManager::addAlterResult()
*/
interface AlterResultInterface extends ProcessResultInterface
{
}

View file

@ -0,0 +1,37 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Call hooks
*/
class CommandEventHookDispatcher extends HookDispatcher
{
/**
* @param ConsoleCommandEvent $event
*/
public function callCommandEventHooks(ConsoleCommandEvent $event)
{
$hooks = [
HookManager::PRE_COMMAND_EVENT,
HookManager::COMMAND_EVENT,
HookManager::POST_COMMAND_EVENT
];
$commandEventHooks = $this->getHooks($hooks);
foreach ($commandEventHooks as $commandEvent) {
if ($commandEvent instanceof EventDispatcherInterface) {
$commandEvent->dispatch(ConsoleEvents::COMMAND, $event);
}
if (is_callable($commandEvent)) {
$commandEvent($event);
}
}
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\Hooks\ExtractOutputInterface;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\OutputDataInterface;
/**
* Call hooks
*/
class ExtracterHookDispatcher extends HookDispatcher implements ExtractOutputInterface
{
/**
* Convert the result object to printable output in
* structured form.
*/
public function extractOutput($result)
{
if ($result instanceof OutputDataInterface) {
return $result->getOutputData();
}
$hooks = [
HookManager::EXTRACT_OUTPUT,
];
$extractors = $this->getHooks($hooks);
foreach ($extractors as $extractor) {
$structuredOutput = $this->callExtractor($extractor, $result);
if (isset($structuredOutput)) {
return $structuredOutput;
}
}
return $result;
}
protected function callExtractor($extractor, $result)
{
if ($extractor instanceof ExtractOutputInterface) {
return $extractor->extractOutput($result);
}
if (is_callable($extractor)) {
return $extractor($result);
}
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\AnnotationData;
/**
* Call hooks
*/
class HookDispatcher
{
/** var HookManager */
protected $hookManager;
protected $names;
public function __construct(HookManager $hookManager, $names)
{
$this->hookManager = $hookManager;
$this->names = $names;
}
public function getHooks($hooks, $annotationData = null)
{
return $this->hookManager->getHooks($this->names, $hooks, $annotationData);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\Hooks\InitializeHookInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
/**
* Call hooks
*/
class InitializeHookDispatcher extends HookDispatcher implements InitializeHookInterface
{
public function initialize(
InputInterface $input,
AnnotationData $annotationData
) {
$hooks = [
HookManager::PRE_INITIALIZE,
HookManager::INITIALIZE,
HookManager::POST_INITIALIZE
];
$providers = $this->getHooks($hooks, $annotationData);
foreach ($providers as $provider) {
$this->callInitializeHook($provider, $input, $annotationData);
}
}
protected function callInitializeHook($provider, $input, AnnotationData $annotationData)
{
if ($provider instanceof InitializeHookInterface) {
return $provider->initialize($input, $annotationData);
}
if (is_callable($provider)) {
return $provider($input, $annotationData);
}
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\InteractorInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Call hooks
*/
class InteractHookDispatcher extends HookDispatcher
{
public function interact(
InputInterface $input,
OutputInterface $output,
AnnotationData $annotationData
) {
$hooks = [
HookManager::PRE_INTERACT,
HookManager::INTERACT,
HookManager::POST_INTERACT
];
$interactors = $this->getHooks($hooks, $annotationData);
foreach ($interactors as $interactor) {
$this->callInteractor($interactor, $input, $output, $annotationData);
}
}
protected function callInteractor($interactor, $input, $output, AnnotationData $annotationData)
{
if ($interactor instanceof InteractorInterface) {
return $interactor->interact($input, $output, $annotationData);
}
if (is_callable($interactor)) {
return $interactor($input, $output, $annotationData);
}
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Symfony\Component\Console\Command\Command;
use Consolidation\AnnotatedCommand\AnnotatedCommand;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\OptionHookInterface;
/**
* Call hooks
*/
class OptionsHookDispatcher extends HookDispatcher implements OptionHookInterface
{
public function getOptions(
Command $command,
AnnotationData $annotationData
) {
$hooks = [
HookManager::PRE_OPTION_HOOK,
HookManager::OPTION_HOOK,
HookManager::POST_OPTION_HOOK
];
$optionHooks = $this->getHooks($hooks, $annotationData);
foreach ($optionHooks as $optionHook) {
$this->callOptionHook($optionHook, $command, $annotationData);
}
$commandInfoList = $this->hookManager->getHookOptionsForCommand($command);
if ($command instanceof AnnotatedCommand) {
$command->optionsHookForHookAnnotations($commandInfoList);
}
}
protected function callOptionHook($optionHook, $command, AnnotationData $annotationData)
{
if ($optionHook instanceof OptionHookInterface) {
return $optionHook->getOptions($command, $annotationData);
}
if (is_callable($optionHook)) {
return $optionHook($command, $annotationData);
}
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\ProcessResultInterface;
/**
* Call hooks
*/
class ProcessResultHookDispatcher extends HookDispatcher implements ProcessResultInterface
{
/**
* Process result and decide what to do with it.
* Allow client to add transformation / interpretation
* callbacks.
*/
public function process($result, CommandData $commandData)
{
$hooks = [
HookManager::PRE_PROCESS_RESULT,
HookManager::PROCESS_RESULT,
HookManager::POST_PROCESS_RESULT,
HookManager::PRE_ALTER_RESULT,
HookManager::ALTER_RESULT,
HookManager::POST_ALTER_RESULT,
HookManager::POST_COMMAND_HOOK,
];
$processors = $this->getHooks($hooks, $commandData->annotationData());
foreach ($processors as $processor) {
$result = $this->callProcessor($processor, $result, $commandData);
}
return $result;
}
protected function callProcessor($processor, $result, CommandData $commandData)
{
$processed = null;
if ($processor instanceof ProcessResultInterface) {
$processed = $processor->process($result, $commandData);
}
if (is_callable($processor)) {
$processed = $processor($result, $commandData);
}
if (isset($processed)) {
return $processed;
}
return $result;
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
/**
* Call hooks.
*/
class ReplaceCommandHookDispatcher extends HookDispatcher implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @return int
*/
public function hasReplaceCommandHook()
{
return (bool) count($this->getReplaceCommandHooks());
}
/**
* @return \callable[]
*/
public function getReplaceCommandHooks()
{
$hooks = [
HookManager::REPLACE_COMMAND_HOOK,
];
$replaceCommandHooks = $this->getHooks($hooks);
return $replaceCommandHooks;
}
/**
* @param \Consolidation\AnnotatedCommand\CommandData $commandData
*
* @return callable
*/
public function getReplacementCommand(CommandData $commandData)
{
$replaceCommandHooks = $this->getReplaceCommandHooks();
// We only take the first hook implementation of "replace-command" as the replacement. Commands shouldn't have
// more than one replacement.
$replacementCommand = reset($replaceCommandHooks);
if ($this->logger && count($replaceCommandHooks) > 1) {
$command_name = $commandData->annotationData()->get('command', 'unknown');
$message = "Multiple implementations of the \"replace - command\" hook exist for the \"$command_name\" command.\n";
foreach ($replaceCommandHooks as $replaceCommandHook) {
$class = get_class($replaceCommandHook[0]);
$method = $replaceCommandHook[1];
$hook_name = "$class->$method";
$message .= " - $hook_name\n";
}
$this->logger->warning($message);
}
return $replacementCommand;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\ExitCodeInterface;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\StatusDeterminerInterface;
/**
* Call hooks
*/
class StatusDeterminerHookDispatcher extends HookDispatcher implements StatusDeterminerInterface
{
/**
* Call all status determiners, and see if any of them
* know how to convert to a status code.
*/
public function determineStatusCode($result)
{
// If the result (post-processing) is an object that
// implements ExitCodeInterface, then we will ask it
// to give us the status code.
if ($result instanceof ExitCodeInterface) {
return $result->getExitCode();
}
$hooks = [
HookManager::STATUS_DETERMINER,
];
// If the result does not implement ExitCodeInterface,
// then we'll see if there is a determiner that can
// extract a status code from the result.
$determiners = $this->getHooks($hooks);
foreach ($determiners as $determiner) {
$status = $this->callDeterminer($determiner, $result);
if (isset($status)) {
return $status;
}
}
}
protected function callDeterminer($determiner, $result)
{
if ($determiner instanceof StatusDeterminerInterface) {
return $determiner->determineStatusCode($result);
}
if (is_callable($determiner)) {
return $determiner($result);
}
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks\Dispatchers;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\CommandError;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\AnnotatedCommand\Hooks\ValidatorInterface;
/**
* Call hooks
*/
class ValidateHookDispatcher extends HookDispatcher implements ValidatorInterface
{
public function validate(CommandData $commandData)
{
$hooks = [
HookManager::PRE_ARGUMENT_VALIDATOR,
HookManager::ARGUMENT_VALIDATOR,
HookManager::POST_ARGUMENT_VALIDATOR,
HookManager::PRE_COMMAND_HOOK,
HookManager::COMMAND_HOOK,
];
$validators = $this->getHooks($hooks, $commandData->annotationData());
foreach ($validators as $validator) {
$validated = $this->callValidator($validator, $commandData);
if ($validated === false) {
return new CommandError();
}
if (is_object($validated)) {
return $validated;
}
}
}
protected function callValidator($validator, CommandData $commandData)
{
if ($validator instanceof ValidatorInterface) {
return $validator->validate($commandData);
}
if (is_callable($validator)) {
return $validator($commandData);
}
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
/**
* Extract Output hooks are used to select the particular
* data elements of the result that should be printed as
* the command output -- perhaps after being formatted.
*
* @see HookManager::addOutputExtractor()
*/
interface ExtractOutputInterface
{
public function extractOutput($result);
}

View file

@ -0,0 +1,434 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Consolidation\AnnotatedCommand\ExitCodeInterface;
use Consolidation\AnnotatedCommand\OutputDataInterface;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\CommandError;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\CommandEventHookDispatcher;
/**
* Manage named callback hooks
*/
class HookManager implements EventSubscriberInterface
{
protected $hooks = [];
/** var CommandInfo[] */
protected $hookOptions = [];
const REPLACE_COMMAND_HOOK = 'replace-command';
const PRE_COMMAND_EVENT = 'pre-command-event';
const COMMAND_EVENT = 'command-event';
const POST_COMMAND_EVENT = 'post-command-event';
const PRE_OPTION_HOOK = 'pre-option';
const OPTION_HOOK = 'option';
const POST_OPTION_HOOK = 'post-option';
const PRE_INITIALIZE = 'pre-init';
const INITIALIZE = 'init';
const POST_INITIALIZE = 'post-init';
const PRE_INTERACT = 'pre-interact';
const INTERACT = 'interact';
const POST_INTERACT = 'post-interact';
const PRE_ARGUMENT_VALIDATOR = 'pre-validate';
const ARGUMENT_VALIDATOR = 'validate';
const POST_ARGUMENT_VALIDATOR = 'post-validate';
const PRE_COMMAND_HOOK = 'pre-command';
const COMMAND_HOOK = 'command';
const POST_COMMAND_HOOK = 'post-command';
const PRE_PROCESS_RESULT = 'pre-process';
const PROCESS_RESULT = 'process';
const POST_PROCESS_RESULT = 'post-process';
const PRE_ALTER_RESULT = 'pre-alter';
const ALTER_RESULT = 'alter';
const POST_ALTER_RESULT = 'post-alter';
const STATUS_DETERMINER = 'status';
const EXTRACT_OUTPUT = 'extract';
const ON_EVENT = 'on-event';
public function __construct()
{
}
public function getAllHooks()
{
return $this->hooks;
}
/**
* Add a hook
*
* @param mixed $callback The callback function to call
* @param string $hook The name of the hook to add
* @param string $name The name of the command to hook
* ('*' for all)
*/
public function add(callable $callback, $hook, $name = '*')
{
if (empty($name)) {
$name = static::getClassNameFromCallback($callback);
}
$this->hooks[$name][$hook][] = $callback;
return $this;
}
public function recordHookOptions($commandInfo, $name)
{
$this->hookOptions[$name][] = $commandInfo;
return $this;
}
public static function getNames($command, $callback)
{
return array_filter(
array_merge(
static::getNamesUsingCommands($command),
[static::getClassNameFromCallback($callback)]
)
);
}
protected static function getNamesUsingCommands($command)
{
return array_merge(
[$command->getName()],
$command->getAliases()
);
}
/**
* If a command hook does not specify any particular command
* name that it should be attached to, then it will be applied
* to every command that is defined in the same class as the hook.
* This is controlled by using the namespace + class name of
* the implementing class of the callback hook.
*/
protected static function getClassNameFromCallback($callback)
{
if (!is_array($callback)) {
return '';
}
$reflectionClass = new \ReflectionClass($callback[0]);
return $reflectionClass->getName();
}
/**
* Add a replace command hook
*
* @param type ReplaceCommandHookInterface $provider
* @param type string $command_name The name of the command to replace
*/
public function addReplaceCommandHook(ReplaceCommandHookInterface $replaceCommandHook, $name)
{
$this->hooks[$name][self::REPLACE_COMMAND_HOOK][] = $replaceCommandHook;
return $this;
}
public function addPreCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
{
$this->hooks[$name][self::PRE_COMMAND_EVENT][] = $eventDispatcher;
return $this;
}
public function addCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
{
$this->hooks[$name][self::COMMAND_EVENT][] = $eventDispatcher;
return $this;
}
public function addPostCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
{
$this->hooks[$name][self::POST_COMMAND_EVENT][] = $eventDispatcher;
return $this;
}
public function addCommandEvent(EventSubscriberInterface $eventSubscriber)
{
// Wrap the event subscriber in a dispatcher and add it
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber($eventSubscriber);
return $this->addCommandEventDispatcher($dispatcher);
}
/**
* Add an configuration provider hook
*
* @param type InitializeHookInterface $provider
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addInitializeHook(InitializeHookInterface $initializeHook, $name = '*')
{
$this->hooks[$name][self::INITIALIZE][] = $initializeHook;
return $this;
}
/**
* Add an option hook
*
* @param type ValidatorInterface $validator
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addOptionHook(OptionHookInterface $interactor, $name = '*')
{
$this->hooks[$name][self::INTERACT][] = $interactor;
return $this;
}
/**
* Add an interact hook
*
* @param type ValidatorInterface $validator
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addInteractor(InteractorInterface $interactor, $name = '*')
{
$this->hooks[$name][self::INTERACT][] = $interactor;
return $this;
}
/**
* Add a pre-validator hook
*
* @param type ValidatorInterface $validator
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addPreValidator(ValidatorInterface $validator, $name = '*')
{
$this->hooks[$name][self::PRE_ARGUMENT_VALIDATOR][] = $validator;
return $this;
}
/**
* Add a validator hook
*
* @param type ValidatorInterface $validator
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addValidator(ValidatorInterface $validator, $name = '*')
{
$this->hooks[$name][self::ARGUMENT_VALIDATOR][] = $validator;
return $this;
}
/**
* Add a pre-command hook. This is the same as a validator hook, except
* that it will run after all of the post-validator hooks.
*
* @param type ValidatorInterface $preCommand
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addPreCommandHook(ValidatorInterface $preCommand, $name = '*')
{
$this->hooks[$name][self::PRE_COMMAND_HOOK][] = $preCommand;
return $this;
}
/**
* Add a post-command hook. This is the same as a pre-process hook,
* except that it will run before the first pre-process hook.
*
* @param type ProcessResultInterface $postCommand
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addPostCommandHook(ProcessResultInterface $postCommand, $name = '*')
{
$this->hooks[$name][self::POST_COMMAND_HOOK][] = $postCommand;
return $this;
}
/**
* Add a result processor.
*
* @param type ProcessResultInterface $resultProcessor
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addResultProcessor(ProcessResultInterface $resultProcessor, $name = '*')
{
$this->hooks[$name][self::PROCESS_RESULT][] = $resultProcessor;
return $this;
}
/**
* Add a result alterer. After a result is processed
* by a result processor, an alter hook may be used
* to convert the result from one form to another.
*
* @param type AlterResultInterface $resultAlterer
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addAlterResult(AlterResultInterface $resultAlterer, $name = '*')
{
$this->hooks[$name][self::ALTER_RESULT][] = $resultAlterer;
return $this;
}
/**
* Add a status determiner. Usually, a command should return
* an integer on error, or a result object on success (which
* implies a status code of zero). If a result contains the
* status code in some other field, then a status determiner
* can be used to call the appropriate accessor method to
* determine the status code. This is usually not necessary,
* though; a command that fails may return a CommandError
* object, which contains a status code and a result message
* to display.
* @see CommandError::getExitCode()
*
* @param type StatusDeterminerInterface $statusDeterminer
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addStatusDeterminer(StatusDeterminerInterface $statusDeterminer, $name = '*')
{
$this->hooks[$name][self::STATUS_DETERMINER][] = $statusDeterminer;
return $this;
}
/**
* Add an output extractor. If a command returns an object
* object, by default it is passed directly to the output
* formatter (if in use) for rendering. If the result object
* contains more information than just the data to render, though,
* then an output extractor can be used to call the appopriate
* accessor method of the result object to get the data to
* rendered. This is usually not necessary, though; it is preferable
* to have complex result objects implement the OutputDataInterface.
* @see OutputDataInterface::getOutputData()
*
* @param type ExtractOutputInterface $outputExtractor
* @param type $name The name of the command to hook
* ('*' for all)
*/
public function addOutputExtractor(ExtractOutputInterface $outputExtractor, $name = '*')
{
$this->hooks[$name][self::EXTRACT_OUTPUT][] = $outputExtractor;
return $this;
}
public function getHookOptionsForCommand($command)
{
$names = $this->addWildcardHooksToNames($command->getNames(), $command->getAnnotationData());
return $this->getHookOptions($names);
}
/**
* @return CommandInfo[]
*/
public function getHookOptions($names)
{
$result = [];
foreach ($names as $name) {
if (isset($this->hookOptions[$name])) {
$result = array_merge($result, $this->hookOptions[$name]);
}
}
return $result;
}
/**
* Get a set of hooks with the provided name(s). Include the
* pre- and post- hooks, and also include the global hooks ('*')
* in addition to the named hooks provided.
*
* @param string|array $names The name of the function being hooked.
* @param string[] $hooks A list of hooks (e.g. [HookManager::ALTER_RESULT])
*
* @return callable[]
*/
public function getHooks($names, $hooks, $annotationData = null)
{
return $this->get($this->addWildcardHooksToNames($names, $annotationData), $hooks);
}
protected function addWildcardHooksToNames($names, $annotationData = null)
{
$names = array_merge(
(array)$names,
($annotationData == null) ? [] : array_map(function ($item) {
return "@$item";
}, $annotationData->keys())
);
$names[] = '*';
return array_unique($names);
}
/**
* Get a set of hooks with the provided name(s).
*
* @param string|array $names The name of the function being hooked.
* @param string[] $hooks The list of hook names (e.g. [HookManager::ALTER_RESULT])
*
* @return callable[]
*/
public function get($names, $hooks)
{
$result = [];
foreach ((array)$hooks as $hook) {
foreach ((array)$names as $name) {
$result = array_merge($result, $this->getHook($name, $hook));
}
}
return $result;
}
/**
* Get a single named hook.
*
* @param string $name The name of the hooked method
* @param string $hook The specific hook name (e.g. alter)
*
* @return callable[]
*/
public function getHook($name, $hook)
{
if (isset($this->hooks[$name][$hook])) {
return $this->hooks[$name][$hook];
}
return [];
}
/**
* Call the command event hooks.
*
* TODO: This should be moved to CommandEventHookDispatcher, which
* should become the class that implements EventSubscriberInterface.
* This change would break all clients, though, so postpone until next
* major release.
*
* @param ConsoleCommandEvent $event
*/
public function callCommandEventHooks(ConsoleCommandEvent $event)
{
/* @var Command $command */
$command = $event->getCommand();
$dispatcher = new CommandEventHookDispatcher($this, [$command->getName()]);
$dispatcher->callCommandEventHooks($event);
}
/**
* @{@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [ConsoleEvents::COMMAND => 'callCommandEventHooks'];
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Consolidation\AnnotatedCommand\AnnotationData;
use Symfony\Component\Console\Input\InputInterface;
/**
* Non-interactively (e.g. via configuration files) apply configuration values to the Input object.
*
* @see HookManager::addInitializeHook()
*/
interface InitializeHookInterface
{
public function initialize(InputInterface $input, AnnotationData $annotationData);
}

View file

@ -0,0 +1,18 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Consolidation\AnnotatedCommand\AnnotationData;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Interactively supply values for missing required arguments for
* the current command. Note that this hook is not called if
* the --no-interaction flag is set.
*
* @see HookManager::addInteractor()
*/
interface InteractorInterface
{
public function interact(InputInterface $input, OutputInterface $output, AnnotationData $annotationData);
}

View file

@ -0,0 +1,16 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Consolidation\AnnotatedCommand\AnnotationData;
use Symfony\Component\Console\Command\Command;
/**
* Add options to a command.
*
* @see HookManager::addOptionHook()
* @see AnnotatedCommandFactory::addListener()
*/
interface OptionHookInterface
{
public function getOptions(Command $command, AnnotationData $annotationData);
}

View file

@ -0,0 +1,27 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Consolidation\AnnotatedCommand\CommandData;
/**
* A result processor takes a result object, processes it, and
* returns another result object. For example, if a result object
* represents a 'task', then a task-runner hook could run the
* task and return the result from that execution.
*
* @see HookManager::addResultProcessor()
*/
interface ProcessResultInterface
{
/**
* After a command has executed, if the result is something
* that needs to be processed, e.g. a collection of tasks to
* run, then execute it and return the new result.
*
* @param mixed $result Result to (potentially) be processed
* @param CommandData $commandData Reference to commandline arguments and options
*
* @return mixed $result
*/
public function process($result, CommandData $commandData);
}

View file

@ -0,0 +1,18 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
/**
* A StatusDeterminer maps from a result to a status exit code.
*
* @see HookManager::addStatusDeterminer()
*/
interface StatusDeterminerInterface
{
/**
* Convert a result object into a status code, if
* possible. Return null if the result object is unknown.
*
* @return null|integer
*/
public function determineStatusCode($result);
}

View file

@ -0,0 +1,14 @@
<?php
namespace Consolidation\AnnotatedCommand\Hooks;
use Consolidation\AnnotatedCommand\CommandData;
/**
* Validate the arguments for the current command.
*
* @see HookManager::addValidator()
*/
interface ValidatorInterface
{
public function validate(CommandData $commandData);
}

View file

@ -0,0 +1,92 @@
<?php
namespace Consolidation\AnnotatedCommand\Options;
use Consolidation\AnnotatedCommand\AnnotatedCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* AlterOptionsCommandEvent is a subscriber to the Command Event
* that looks up any additional options (e.g. from an OPTION_HOOK)
* that should be added to the command. Options need to be added
* in two circumstances:
*
* 1. When 'help' for the command is called, so that the additional
* command options may be listed in the command description.
*
* 2. When the command itself is called, so that option validation
* may be done.
*
* We defer the addition of options until these times so that we
* do not invoke the option hooks for every command on every run
* of the program, and so that we do not need to defer the addition
* of all of the application hooks until after all of the application
* commands have been added. (Hooks may appear in the same command files
* as command implementations; applications may support command file
* plug-ins, and hooks may add options to commands defined in other
* commandfiles.)
*/
class AlterOptionsCommandEvent implements EventSubscriberInterface
{
/** var Application */
protected $application;
public function __construct(Application $application)
{
$this->application = $application;
}
/**
* @param ConsoleCommandEvent $event
*/
public function alterCommandOptions(ConsoleCommandEvent $event)
{
/* @var Command $command */
$command = $event->getCommand();
$input = $event->getInput();
if ($command->getName() == 'help') {
// Symfony 3.x prepares $input for us; Symfony 2.x, on the other
// hand, passes it in prior to binding with the command definition,
// so we have to go to a little extra work. It may be inadvisable
// to do these steps for commands other than 'help'.
if (!$input->hasArgument('command_name')) {
$command->ignoreValidationErrors();
$command->mergeApplicationDefinition();
$input->bind($command->getDefinition());
}
// Symfony Console helpfully swaps 'command_name' and 'command'
// depending on whether the user entered `help foo` or `--help foo`.
// One of these is always `help`, and the other is the command we
// are actually interested in.
$nameOfCommandToDescribe = $event->getInput()->getArgument('command_name');
if ($nameOfCommandToDescribe == 'help') {
$nameOfCommandToDescribe = $event->getInput()->getArgument('command');
}
$commandToDescribe = $this->application->find($nameOfCommandToDescribe);
$this->findAndAddHookOptions($commandToDescribe);
} else {
$this->findAndAddHookOptions($command);
}
}
public function findAndAddHookOptions($command)
{
if (!$command instanceof AnnotatedCommand) {
return;
}
$command->optionsHook();
}
/**
* @{@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [ConsoleEvents::COMMAND => 'alterCommandOptions'];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Consolidation\AnnotatedCommand\Options;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
use Symfony\Component\Console\Input\InputOption;
/**
* Option providers can add options to commands based on the annotations
* present in a command. For example, a command that specifies @fields
* will automatically be given --format and --fields options.
*
* @see AnnotatedCommandFactory::addListener()
* @see HookManager::addOptionHook()
*/
interface AutomaticOptionsProviderInterface
{
/**
* @return InputOption[]
*/
public function automaticOptions(CommandInfo $commandInfo);
}

View file

@ -0,0 +1,10 @@
<?php
namespace Consolidation\AnnotatedCommand\Options;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\OutputFormatters\Options\FormatterOptions;
interface PrepareFormatter
{
public function prepare(CommandData $commandData, FormatterOptions $options);
}

View file

@ -0,0 +1,106 @@
<?php
namespace Consolidation\AnnotatedCommand\Options;
use Symfony\Component\Console\Application;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\OutputFormatters\Options\FormatterOptions;
class PrepareTerminalWidthOption implements PrepareFormatter
{
/** var Application */
protected $application;
protected $terminal;
/** var int */
protected $defaultWidth;
/** var int */
protected $maxWidth = PHP_INT_MAX;
/** var int */
protected $minWidth = 0;
/* var boolean */
protected $shouldWrap = true;
public function __construct($defaultWidth = 0)
{
$this->defaultWidth = $defaultWidth;
}
public function setApplication(Application $application)
{
$this->application = $application;
}
public function setTerminal($terminal)
{
$this->terminal = $terminal;
}
public function getTerminal()
{
if (!$this->terminal && class_exists('\Symfony\Component\Console\Terminal')) {
$this->terminal = new \Symfony\Component\Console\Terminal();
}
return $this->terminal;
}
public function enableWrap($shouldWrap)
{
$this->shouldWrap = $shouldWrap;
}
public function prepare(CommandData $commandData, FormatterOptions $options)
{
$width = $this->getTerminalWidth();
if (!$width) {
$width = $this->defaultWidth;
}
// Enforce minimum and maximum widths
$width = min($width, $this->getMaxWidth($commandData));
$width = max($width, $this->getMinWidth($commandData));
$options->setWidth($width);
}
protected function getTerminalWidth()
{
// Don't wrap if wrapping has been disabled.
if (!$this->shouldWrap) {
return 0;
}
$terminal = $this->getTerminal();
if ($terminal) {
return $terminal->getWidth();
}
return $this->getTerminalWidthViaApplication();
}
protected function getTerminalWidthViaApplication()
{
if (!$this->application) {
return 0;
}
$dimensions = $this->application->getTerminalDimensions();
if ($dimensions[0] == null) {
return 0;
}
return $dimensions[0];
}
protected function getMaxWidth(CommandData $commandData)
{
return $this->maxWidth;
}
protected function getMinWidth(CommandData $commandData)
{
return $this->minWidth;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Consolidation\AnnotatedCommand;
/**
* If an annotated command method returns an object that
* implements OutputDataInterface, then the getOutputData()
* method is used to fetch the output to print from the
* result object.
*/
interface OutputDataInterface
{
public function getOutputData();
}

View file

@ -0,0 +1,764 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser;
use Symfony\Component\Console\Input\InputOption;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
use Consolidation\AnnotatedCommand\AnnotationData;
/**
* Given a class and method name, parse the annotations in the
* DocBlock comment, and provide accessor methods for all of
* the elements that are needed to create a Symfony Console Command.
*
* Note that the name of this class is now somewhat of a misnomer,
* as we now use it to hold annotation data for hooks as well as commands.
* It would probably be better to rename this to MethodInfo at some point.
*/
class CommandInfo
{
/**
* Serialization schema version. Incremented every time the serialization schema changes.
*/
const SERIALIZATION_SCHEMA_VERSION = 3;
/**
* @var \ReflectionMethod
*/
protected $reflection;
/**
* @var boolean
* @var string
*/
protected $docBlockIsParsed = false;
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $description = '';
/**
* @var string
*/
protected $help = '';
/**
* @var DefaultsWithDescriptions
*/
protected $options;
/**
* @var DefaultsWithDescriptions
*/
protected $arguments;
/**
* @var array
*/
protected $exampleUsage = [];
/**
* @var AnnotationData
*/
protected $otherAnnotations;
/**
* @var array
*/
protected $aliases = [];
/**
* @var InputOption[]
*/
protected $inputOptions;
/**
* @var string
*/
protected $methodName;
/**
* @var string
*/
protected $returnType;
/**
* Create a new CommandInfo class for a particular method of a class.
*
* @param string|mixed $classNameOrInstance The name of a class, or an
* instance of it, or an array of cached data.
* @param string $methodName The name of the method to get info about.
* @param array $cache Cached data
* @deprecated Use CommandInfo::create() or CommandInfo::deserialize()
* instead. In the future, this constructor will be protected.
*/
public function __construct($classNameOrInstance, $methodName, $cache = [])
{
$this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
$this->methodName = $methodName;
$this->arguments = new DefaultsWithDescriptions();
$this->options = new DefaultsWithDescriptions();
// If the cache came from a newer version, ignore it and
// regenerate the cached information.
if (!empty($cache) && CommandInfoDeserializer::isValidSerializedData($cache) && !$this->cachedFileIsModified($cache)) {
$deserializer = new CommandInfoDeserializer();
$deserializer->constructFromCache($this, $cache);
$this->docBlockIsParsed = true;
} else {
$this->constructFromClassAndMethod($classNameOrInstance, $methodName);
}
}
public static function create($classNameOrInstance, $methodName)
{
return new self($classNameOrInstance, $methodName);
}
public static function deserialize($cache)
{
$cache = (array)$cache;
return new self($cache['class'], $cache['method_name'], $cache);
}
public function cachedFileIsModified($cache)
{
$path = $this->reflection->getFileName();
return filemtime($path) != $cache['mtime'];
}
protected function constructFromClassAndMethod($classNameOrInstance, $methodName)
{
$this->otherAnnotations = new AnnotationData();
// Set up a default name for the command from the method name.
// This can be overridden via @command or @name annotations.
$this->name = $this->convertName($methodName);
$this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
$this->arguments = $this->determineAgumentClassifications();
}
/**
* Recover the method name provided to the constructor.
*
* @return string
*/
public function getMethodName()
{
return $this->methodName;
}
/**
* Return the primary name for this command.
*
* @return string
*/
public function getName()
{
$this->parseDocBlock();
return $this->name;
}
/**
* Set the primary name for this command.
*
* @param string $name
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Return whether or not this method represents a valid command
* or hook.
*/
public function valid()
{
return !empty($this->name);
}
/**
* If higher-level code decides that this CommandInfo is not interesting
* or useful (if it is not a command method or a hook method), then
* we will mark it as invalid to prevent it from being created as a command.
* We still cache a placeholder record for invalid methods, so that we
* do not need to re-parse the method again later simply to determine that
* it is invalid.
*/
public function invalidate()
{
$this->name = '';
}
public function getReturnType()
{
$this->parseDocBlock();
return $this->returnType;
}
public function setReturnType($returnType)
{
$this->returnType = $returnType;
return $this;
}
/**
* Get any annotations included in the docblock comment for the
* implementation method of this command that are not already
* handled by the primary methods of this class.
*
* @return AnnotationData
*/
public function getRawAnnotations()
{
$this->parseDocBlock();
return $this->otherAnnotations;
}
/**
* Replace the annotation data.
*/
public function replaceRawAnnotations($annotationData)
{
$this->otherAnnotations = new AnnotationData((array) $annotationData);
return $this;
}
/**
* Get any annotations included in the docblock comment,
* also including default values such as @command. We add
* in the default @command annotation late, and only in a
* copy of the annotation data because we use the existance
* of a @command to indicate that this CommandInfo is
* a command, and not a hook or anything else.
*
* @return AnnotationData
*/
public function getAnnotations()
{
// Also provide the path to the commandfile that these annotations
// were pulled from and the classname of that file.
$path = $this->reflection->getFileName();
$className = $this->reflection->getDeclaringClass()->getName();
return new AnnotationData(
$this->getRawAnnotations()->getArrayCopy() +
[
'command' => $this->getName(),
'_path' => $path,
'_classname' => $className,
]
);
}
/**
* Return a specific named annotation for this command as a list.
*
* @param string $name The name of the annotation.
* @return array|null
*/
public function getAnnotationList($name)
{
// hasAnnotation parses the docblock
if (!$this->hasAnnotation($name)) {
return null;
}
return $this->otherAnnotations->getList($name);
;
}
/**
* Return a specific named annotation for this command as a string.
*
* @param string $name The name of the annotation.
* @return string|null
*/
public function getAnnotation($name)
{
// hasAnnotation parses the docblock
if (!$this->hasAnnotation($name)) {
return null;
}
return $this->otherAnnotations->get($name);
}
/**
* Check to see if the specified annotation exists for this command.
*
* @param string $annotation The name of the annotation.
* @return boolean
*/
public function hasAnnotation($annotation)
{
$this->parseDocBlock();
return isset($this->otherAnnotations[$annotation]);
}
/**
* Save any tag that we do not explicitly recognize in the
* 'otherAnnotations' map.
*/
public function addAnnotation($name, $content)
{
// Convert to an array and merge if there are multiple
// instances of the same annotation defined.
if (isset($this->otherAnnotations[$name])) {
$content = array_merge((array) $this->otherAnnotations[$name], (array)$content);
}
$this->otherAnnotations[$name] = $content;
}
/**
* Remove an annotation that was previoudly set.
*/
public function removeAnnotation($name)
{
unset($this->otherAnnotations[$name]);
}
/**
* Get the synopsis of the command (~first line).
*
* @return string
*/
public function getDescription()
{
$this->parseDocBlock();
return $this->description;
}
/**
* Set the command description.
*
* @param string $description The description to set.
*/
public function setDescription($description)
{
$this->description = str_replace("\n", ' ', $description);
return $this;
}
/**
* Get the help text of the command (the description)
*/
public function getHelp()
{
$this->parseDocBlock();
return $this->help;
}
/**
* Set the help text for this command.
*
* @param string $help The help text.
*/
public function setHelp($help)
{
$this->help = $help;
return $this;
}
/**
* Return the list of aliases for this command.
* @return string[]
*/
public function getAliases()
{
$this->parseDocBlock();
return $this->aliases;
}
/**
* Set aliases that can be used in place of the command's primary name.
*
* @param string|string[] $aliases
*/
public function setAliases($aliases)
{
if (is_string($aliases)) {
$aliases = explode(',', static::convertListToCommaSeparated($aliases));
}
$this->aliases = array_filter($aliases);
return $this;
}
/**
* Get hidden status for the command.
* @return bool
*/
public function getHidden()
{
$this->parseDocBlock();
return $this->hasAnnotation('hidden');
}
/**
* Set hidden status. List command omits hidden commands.
*
* @param bool $hidden
*/
public function setHidden($hidden)
{
$this->hidden = $hidden;
return $this;
}
/**
* Return the examples for this command. This is @usage instead of
* @example because the later is defined by the phpdoc standard to
* be example method calls.
*
* @return string[]
*/
public function getExampleUsages()
{
$this->parseDocBlock();
return $this->exampleUsage;
}
/**
* Add an example usage for this command.
*
* @param string $usage An example of the command, including the command
* name and all of its example arguments and options.
* @param string $description An explanation of what the example does.
*/
public function setExampleUsage($usage, $description)
{
$this->exampleUsage[$usage] = $description;
return $this;
}
/**
* Overwrite all example usages
*/
public function replaceExampleUsages($usages)
{
$this->exampleUsage = $usages;
return $this;
}
/**
* Return the topics for this command.
*
* @return string[]
*/
public function getTopics()
{
if (!$this->hasAnnotation('topics')) {
return [];
}
$topics = $this->getAnnotation('topics');
return explode(',', trim($topics));
}
/**
* Return the list of refleaction parameters.
*
* @return ReflectionParameter[]
*/
public function getParameters()
{
return $this->reflection->getParameters();
}
/**
* Descriptions of commandline arguements for this command.
*
* @return DefaultsWithDescriptions
*/
public function arguments()
{
return $this->arguments;
}
/**
* Descriptions of commandline options for this command.
*
* @return DefaultsWithDescriptions
*/
public function options()
{
return $this->options;
}
/**
* Get the inputOptions for the options associated with this CommandInfo
* object, e.g. via @option annotations, or from
* $options = ['someoption' => 'defaultvalue'] in the command method
* parameter list.
*
* @return InputOption[]
*/
public function inputOptions()
{
if (!isset($this->inputOptions)) {
$this->inputOptions = $this->createInputOptions();
}
return $this->inputOptions;
}
protected function addImplicitNoOptions()
{
$opts = $this->options()->getValues();
foreach ($opts as $name => $defaultValue) {
if ($defaultValue === true) {
$key = 'no-' . $name;
if (!array_key_exists($key, $opts)) {
$description = "Negate --$name option.";
$this->options()->add($key, $description, false);
}
}
}
}
protected function createInputOptions()
{
$explicitOptions = [];
$this->addImplicitNoOptions();
$opts = $this->options()->getValues();
foreach ($opts as $name => $defaultValue) {
$description = $this->options()->getDescription($name);
$fullName = $name;
$shortcut = '';
if (strpos($name, '|')) {
list($fullName, $shortcut) = explode('|', $name, 2);
}
// Treat the following two cases identically:
// - 'foo' => InputOption::VALUE_OPTIONAL
// - 'foo' => null
// The first form is preferred, but we will convert the value
// to 'null' for storage as the option default value.
if ($defaultValue === InputOption::VALUE_OPTIONAL) {
$defaultValue = null;
}
if ($defaultValue === false) {
$explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
} elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
$explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
} elseif (is_array($defaultValue)) {
$optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
$explicitOptions[$fullName] = new InputOption(
$fullName,
$shortcut,
InputOption::VALUE_IS_ARRAY | $optionality,
$description,
count($defaultValue) ? $defaultValue : null
);
} else {
$explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
}
}
return $explicitOptions;
}
/**
* An option might have a name such as 'silent|s'. In this
* instance, we will allow the @option or @default tag to
* reference the option only by name (e.g. 'silent' or 's'
* instead of 'silent|s').
*
* @param string $optionName
* @return string
*/
public function findMatchingOption($optionName)
{
// Exit fast if there's an exact match
if ($this->options->exists($optionName)) {
return $optionName;
}
$existingOptionName = $this->findExistingOption($optionName);
if (isset($existingOptionName)) {
return $existingOptionName;
}
return $this->findOptionAmongAlternatives($optionName);
}
/**
* @param string $optionName
* @return string
*/
protected function findOptionAmongAlternatives($optionName)
{
// Check the other direction: if the annotation contains @silent|s
// and the options array has 'silent|s'.
$checkMatching = explode('|', $optionName);
if (count($checkMatching) > 1) {
foreach ($checkMatching as $checkName) {
if ($this->options->exists($checkName)) {
$this->options->rename($checkName, $optionName);
return $optionName;
}
}
}
return $optionName;
}
/**
* @param string $optionName
* @return string|null
*/
protected function findExistingOption($optionName)
{
// Check to see if we can find the option name in an existing option,
// e.g. if the options array has 'silent|s' => false, and the annotation
// is @silent.
foreach ($this->options()->getValues() as $name => $default) {
if (in_array($optionName, explode('|', $name))) {
return $name;
}
}
}
/**
* Examine the parameters of the method for this command, and
* build a list of commandline arguements for them.
*
* @return array
*/
protected function determineAgumentClassifications()
{
$result = new DefaultsWithDescriptions();
$params = $this->reflection->getParameters();
$optionsFromParameters = $this->determineOptionsFromParameters();
if ($this->lastParameterIsOptionsArray()) {
array_pop($params);
}
foreach ($params as $param) {
$this->addParameterToResult($result, $param);
}
return $result;
}
/**
* Examine the provided parameter, and determine whether it
* is a parameter that will be filled in with a positional
* commandline argument.
*/
protected function addParameterToResult($result, $param)
{
// Commandline arguments must be strings, so ignore any
// parameter that is typehinted to any non-primative class.
if ($param->getClass() != null) {
return;
}
$result->add($param->name);
if ($param->isDefaultValueAvailable()) {
$defaultValue = $param->getDefaultValue();
if (!$this->isAssoc($defaultValue)) {
$result->setDefaultValue($param->name, $defaultValue);
}
} elseif ($param->isArray()) {
$result->setDefaultValue($param->name, []);
}
}
/**
* Examine the parameters of the method for this command, and determine
* the disposition of the options from them.
*
* @return array
*/
protected function determineOptionsFromParameters()
{
$params = $this->reflection->getParameters();
if (empty($params)) {
return [];
}
$param = end($params);
if (!$param->isDefaultValueAvailable()) {
return [];
}
if (!$this->isAssoc($param->getDefaultValue())) {
return [];
}
return $param->getDefaultValue();
}
/**
* Determine if the last argument contains $options.
*
* Two forms indicate options:
* - $options = []
* - $options = ['flag' => 'default-value']
*
* Any other form, including `array $foo`, is not options.
*/
protected function lastParameterIsOptionsArray()
{
$params = $this->reflection->getParameters();
if (empty($params)) {
return [];
}
$param = end($params);
if (!$param->isDefaultValueAvailable()) {
return [];
}
return is_array($param->getDefaultValue());
}
/**
* Helper; determine if an array is associative or not. An array
* is not associative if its keys are numeric, and numbered sequentially
* from zero. All other arrays are considered to be associative.
*
* @param array $arr The array
* @return boolean
*/
protected function isAssoc($arr)
{
if (!is_array($arr)) {
return false;
}
return array_keys($arr) !== range(0, count($arr) - 1);
}
/**
* Convert from a method name to the corresponding command name. A
* method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
* become 'foo:bar-baz-boz'.
*
* @param string $camel method name.
* @return string
*/
protected function convertName($camel)
{
$splitter="-";
$camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
$camel = preg_replace("/$splitter/", ':', $camel, 1);
return strtolower($camel);
}
/**
* Parse the docBlock comment for this command, and set the
* fields of this class with the data thereby obtained.
*/
protected function parseDocBlock()
{
if (!$this->docBlockIsParsed) {
// The parse function will insert data from the provided method
// into this object, using our accessors.
CommandDocBlockParserFactory::parse($this, $this->reflection);
$this->docBlockIsParsed = true;
}
}
/**
* Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
* convert the data into the last of these forms.
*/
protected static function convertListToCommaSeparated($text)
{
return preg_replace('#[ \t\n\r,]+#', ',', $text);
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser;
use Symfony\Component\Console\Input\InputOption;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
use Consolidation\AnnotatedCommand\AnnotationData;
/**
* Deserialize a CommandInfo object
*/
class CommandInfoDeserializer
{
// TODO: in a future version, move CommandInfo::deserialize here
public function deserialize($data)
{
return CommandInfo::deserialize((array)$data);
}
protected static function cachedMethodExists($cache)
{
return method_exists($cache['class'], $cache['method_name']);
}
public static function isValidSerializedData($cache)
{
return
isset($cache['schema']) &&
isset($cache['method_name']) &&
isset($cache['mtime']) &&
($cache['schema'] > 0) &&
($cache['schema'] <= CommandInfo::SERIALIZATION_SCHEMA_VERSION) &&
self::cachedMethodExists($cache);
}
public function constructFromCache(CommandInfo $commandInfo, $info_array)
{
$info_array += $this->defaultSerializationData();
$commandInfo
->setName($info_array['name'])
->replaceRawAnnotations($info_array['annotations'])
->setAliases($info_array['aliases'])
->setHelp($info_array['help'])
->setDescription($info_array['description'])
->replaceExampleUsages($info_array['example_usages'])
->setReturnType($info_array['return_type'])
;
$this->constructDefaultsWithDescriptions($commandInfo->arguments(), (array)$info_array['arguments']);
$this->constructDefaultsWithDescriptions($commandInfo->options(), (array)$info_array['options']);
}
protected function constructDefaultsWithDescriptions(DefaultsWithDescriptions $defaults, $data)
{
foreach ($data as $key => $info) {
$info = (array)$info;
$defaults->add($key, $info['description']);
if (array_key_exists('default', $info)) {
$defaults->setDefaultValue($key, $info['default']);
}
}
}
/**
* Default data. Everything should be provided during serialization;
* this is just as a fallback for unusual circumstances.
* @return array
*/
protected function defaultSerializationData()
{
return [
'name' => '',
'description' => '',
'help' => '',
'aliases' => [],
'annotations' => [],
'example_usages' => [],
'return_type' => [],
'parameters' => [],
'arguments' => [],
'options' => [],
'mtime' => 0,
];
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser;
use Symfony\Component\Console\Input\InputOption;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
use Consolidation\AnnotatedCommand\AnnotationData;
/**
* Serialize a CommandInfo object
*/
class CommandInfoSerializer
{
public function serialize(CommandInfo $commandInfo)
{
$allAnnotations = $commandInfo->getAnnotations();
$path = $allAnnotations['_path'];
$className = $allAnnotations['_classname'];
// Include the minimum information for command info (including placeholder records)
$info = [
'schema' => CommandInfo::SERIALIZATION_SCHEMA_VERSION,
'class' => $className,
'method_name' => $commandInfo->getMethodName(),
'mtime' => filemtime($path),
];
// If this is a valid method / hook, then add more information.
if ($commandInfo->valid()) {
$info += [
'name' => $commandInfo->getName(),
'description' => $commandInfo->getDescription(),
'help' => $commandInfo->getHelp(),
'aliases' => $commandInfo->getAliases(),
'annotations' => $commandInfo->getRawAnnotations()->getArrayCopy(),
'example_usages' => $commandInfo->getExampleUsages(),
'return_type' => $commandInfo->getReturnType(),
];
$info['arguments'] = $this->serializeDefaultsWithDescriptions($commandInfo->arguments());
$info['options'] = $this->serializeDefaultsWithDescriptions($commandInfo->options());
}
return $info;
}
protected function serializeDefaultsWithDescriptions(DefaultsWithDescriptions $defaults)
{
$result = [];
foreach ($defaults->getValues() as $key => $val) {
$result[$key] = [
'description' => $defaults->getDescription($key),
];
if ($defaults->hasDefault($key)) {
$result[$key]['default'] = $val;
}
}
return $result;
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser;
/**
* An associative array that maps from key to default value;
* each entry can also have a description.
*/
class DefaultsWithDescriptions
{
/**
* @var array Associative array of key : default mappings
*/
protected $values;
/**
* @var array Associative array used like a set to indicate default value
* exists for the key.
*/
protected $hasDefault;
/**
* @var array Associative array of key : description mappings
*/
protected $descriptions;
/**
* @var mixed Default value that the default value of items in
* the collection should take when not specified in the 'add' method.
*/
protected $defaultDefault;
public function __construct($values = [], $defaultDefault = null)
{
$this->values = $values;
$this->hasDefault = array_filter($this->values, function ($value) {
return isset($value);
});
$this->descriptions = [];
$this->defaultDefault = $defaultDefault;
}
/**
* Return just the key : default values mapping
*
* @return array
*/
public function getValues()
{
return $this->values;
}
/**
* Return true if this set of options is empty
*
* @return
*/
public function isEmpty()
{
return empty($this->values);
}
/**
* Check to see whether the speicifed key exists in the collection.
*
* @param string $key
* @return boolean
*/
public function exists($key)
{
return array_key_exists($key, $this->values);
}
/**
* Get the value of one entry.
*
* @param string $key The key of the item.
* @return string
*/
public function get($key)
{
if (array_key_exists($key, $this->values)) {
return $this->values[$key];
}
return $this->defaultDefault;
}
/**
* Get the description of one entry.
*
* @param string $key The key of the item.
* @return string
*/
public function getDescription($key)
{
if (array_key_exists($key, $this->descriptions)) {
return $this->descriptions[$key];
}
return '';
}
/**
* Add another argument to this command.
*
* @param string $key Name of the argument.
* @param string $description Help text for the argument.
* @param mixed $defaultValue The default value for the argument.
*/
public function add($key, $description = '', $defaultValue = null)
{
if (!$this->exists($key) || isset($defaultValue)) {
$this->values[$key] = isset($defaultValue) ? $defaultValue : $this->defaultDefault;
}
unset($this->descriptions[$key]);
if (!empty($description)) {
$this->descriptions[$key] = $description;
}
}
/**
* Change the default value of an entry.
*
* @param string $key
* @param mixed $defaultValue
*/
public function setDefaultValue($key, $defaultValue)
{
$this->values[$key] = $defaultValue;
$this->hasDefault[$key] = true;
return $this;
}
/**
* Check to see if the named argument definitively has a default value.
*
* @param string $key
* @return bool
*/
public function hasDefault($key)
{
return array_key_exists($key, $this->hasDefault);
}
/**
* Remove an entry
*
* @param string $key The entry to remove
*/
public function clear($key)
{
unset($this->values[$key]);
unset($this->descriptions[$key]);
}
/**
* Rename an existing option to something else.
*/
public function rename($oldName, $newName)
{
$this->add($newName, $this->getDescription($oldName), $this->get($oldName));
$this->clear($oldName);
}
}

View file

@ -0,0 +1,322 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
use Consolidation\AnnotatedCommand\Parser\DefaultsWithDescriptions;
/**
* Given a class and method name, parse the annotations in the
* DocBlock comment, and provide accessor methods for all of
* the elements that are needed to create an annotated Command.
*/
class BespokeDocBlockParser
{
protected $fqcnCache;
/**
* @var array
*/
protected $tagProcessors = [
'command' => 'processCommandTag',
'name' => 'processCommandTag',
'arg' => 'processArgumentTag',
'param' => 'processArgumentTag',
'return' => 'processReturnTag',
'option' => 'processOptionTag',
'default' => 'processDefaultTag',
'aliases' => 'processAliases',
'usage' => 'processUsageTag',
'description' => 'processAlternateDescriptionTag',
'desc' => 'processAlternateDescriptionTag',
];
public function __construct(CommandInfo $commandInfo, \ReflectionMethod $reflection, $fqcnCache = null)
{
$this->commandInfo = $commandInfo;
$this->reflection = $reflection;
$this->fqcnCache = $fqcnCache ?: new FullyQualifiedClassCache();
}
/**
* Parse the docBlock comment for this command, and set the
* fields of this class with the data thereby obtained.
*/
public function parse()
{
$doc = $this->reflection->getDocComment();
$this->parseDocBlock($doc);
}
/**
* Save any tag that we do not explicitly recognize in the
* 'otherAnnotations' map.
*/
protected function processGenericTag($tag)
{
$this->commandInfo->addAnnotation($tag->getTag(), $tag->getContent());
}
/**
* Set the name of the command from a @command or @name annotation.
*/
protected function processCommandTag($tag)
{
if (!$tag->hasWordAndDescription($matches)) {
throw new \Exception('Could not determine command name from tag ' . (string)$tag);
}
$commandName = $matches['word'];
$this->commandInfo->setName($commandName);
// We also store the name in the 'other annotations' so that is is
// possible to determine if the method had a @command annotation.
$this->commandInfo->addAnnotation($tag->getTag(), $commandName);
}
/**
* The @description and @desc annotations may be used in
* place of the synopsis (which we call 'description').
* This is discouraged.
*
* @deprecated
*/
protected function processAlternateDescriptionTag($tag)
{
$this->commandInfo->setDescription($tag->getContent());
}
/**
* Store the data from a @arg annotation in our argument descriptions.
*/
protected function processArgumentTag($tag)
{
if (!$tag->hasVariable($matches)) {
throw new \Exception('Could not determine argument name from tag ' . (string)$tag);
}
if ($matches['variable'] == $this->optionParamName()) {
return;
}
$this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $matches['variable'], $matches['description']);
}
/**
* Store the data from an @option annotation in our option descriptions.
*/
protected function processOptionTag($tag)
{
if (!$tag->hasVariable($matches)) {
throw new \Exception('Could not determine option name from tag ' . (string)$tag);
}
$this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $matches['variable'], $matches['description']);
}
protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $name, $description)
{
$variableName = $this->commandInfo->findMatchingOption($name);
$description = static::removeLineBreaks($description);
$set->add($variableName, $description);
}
/**
* Store the data from a @default annotation in our argument or option store,
* as appropriate.
*/
protected function processDefaultTag($tag)
{
if (!$tag->hasVariable($matches)) {
throw new \Exception('Could not determine parameter name for default value from tag ' . (string)$tag);
}
$variableName = $matches['variable'];
$defaultValue = $this->interpretDefaultValue($matches['description']);
if ($this->commandInfo->arguments()->exists($variableName)) {
$this->commandInfo->arguments()->setDefaultValue($variableName, $defaultValue);
return;
}
$variableName = $this->commandInfo->findMatchingOption($variableName);
if ($this->commandInfo->options()->exists($variableName)) {
$this->commandInfo->options()->setDefaultValue($variableName, $defaultValue);
}
}
/**
* Store the data from a @usage annotation in our example usage list.
*/
protected function processUsageTag($tag)
{
$lines = explode("\n", $tag->getContent());
$usage = trim(array_shift($lines));
$description = static::removeLineBreaks(implode("\n", array_map(function ($line) {
return trim($line);
}, $lines)));
$this->commandInfo->setExampleUsage($usage, $description);
}
/**
* Process the comma-separated list of aliases
*/
protected function processAliases($tag)
{
$this->commandInfo->setAliases((string)$tag->getContent());
}
/**
* Store the data from a @return annotation in our argument descriptions.
*/
protected function processReturnTag($tag)
{
// The return type might be a variable -- '$this'. It will
// usually be a type, like RowsOfFields, or \Namespace\RowsOfFields.
if (!$tag->hasVariableAndDescription($matches)) {
throw new \Exception('Could not determine return type from tag ' . (string)$tag);
}
// Look at namespace and `use` statments to make returnType a fqdn
$returnType = $matches['variable'];
$returnType = $this->findFullyQualifiedClass($returnType);
$this->commandInfo->setReturnType($returnType);
}
protected function findFullyQualifiedClass($className)
{
if (strpos($className, '\\') !== false) {
return $className;
}
return $this->fqcnCache->qualify($this->reflection->getFileName(), $className);
}
private function parseDocBlock($doc)
{
// Remove the leading /** and the trailing */
$doc = preg_replace('#^\s*/\*+\s*#', '', $doc);
$doc = preg_replace('#\s*\*+/\s*#', '', $doc);
// Nothing left? Exit.
if (empty($doc)) {
return;
}
$tagFactory = new TagFactory();
$lines = [];
foreach (explode("\n", $doc) as $row) {
// Remove trailing whitespace and leading space + '*'s
$row = rtrim($row);
$row = preg_replace('#^[ \t]*\**#', '', $row);
if (!$tagFactory->parseLine($row)) {
$lines[] = $row;
}
}
$this->processDescriptionAndHelp($lines);
$this->processAllTags($tagFactory->getTags());
}
protected function processDescriptionAndHelp($lines)
{
// Trim all of the lines individually.
$lines =
array_map(
function ($line) {
return trim($line);
},
$lines
);
// Everything up to the first blank line goes in the description.
$description = array_shift($lines);
while ($this->nextLineIsNotEmpty($lines)) {
$description .= ' ' . array_shift($lines);
}
// Everything else goes in the help.
$help = trim(implode("\n", $lines));
$this->commandInfo->setDescription($description);
$this->commandInfo->setHelp($help);
}
protected function nextLineIsNotEmpty($lines)
{
if (empty($lines)) {
return false;
}
$nextLine = trim($lines[0]);
return !empty($nextLine);
}
protected function processAllTags($tags)
{
// Iterate over all of the tags, and process them as necessary.
foreach ($tags as $tag) {
$processFn = [$this, 'processGenericTag'];
if (array_key_exists($tag->getTag(), $this->tagProcessors)) {
$processFn = [$this, $this->tagProcessors[$tag->getTag()]];
}
$processFn($tag);
}
}
protected function lastParameterName()
{
$params = $this->commandInfo->getParameters();
$param = end($params);
if (!$param) {
return '';
}
return $param->name;
}
/**
* Return the name of the last parameter if it holds the options.
*/
public function optionParamName()
{
// Remember the name of the last parameter, if it holds the options.
// We will use this information to ignore @param annotations for the options.
if (!isset($this->optionParamName)) {
$this->optionParamName = '';
$options = $this->commandInfo->options();
if (!$options->isEmpty()) {
$this->optionParamName = $this->lastParameterName();
}
}
return $this->optionParamName;
}
protected function interpretDefaultValue($defaultValue)
{
$defaults = [
'null' => null,
'true' => true,
'false' => false,
"''" => '',
'[]' => [],
];
foreach ($defaults as $defaultName => $defaultTypedValue) {
if ($defaultValue == $defaultName) {
return $defaultTypedValue;
}
}
return $defaultValue;
}
/**
* Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
* convert the data into the last of these forms.
*/
protected static function convertListToCommaSeparated($text)
{
return preg_replace('#[ \t\n\r,]+#', ',', $text);
}
/**
* Take a multiline description and convert it into a single
* long unbroken line.
*/
protected static function removeLineBreaks($text)
{
return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
/**
* Create an appropriate CommandDocBlockParser.
*/
class CommandDocBlockParserFactory
{
public static function parse(CommandInfo $commandInfo, \ReflectionMethod $reflection)
{
return static::create($commandInfo, $reflection)->parse();
}
private static function create(CommandInfo $commandInfo, \ReflectionMethod $reflection)
{
return new BespokeDocBlockParser($commandInfo, $reflection);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
/**
* Methods to convert to / from a csv string.
*/
class CsvUtils
{
/**
* Ensure that the provided data is a string.
*
* @param string|array $data The data to convert to a string.
* @return string
*/
public static function toString($data)
{
if (is_array($data)) {
return static::csvEscape($data);
}
return $data;
}
/**
* Convert a string to a csv.
*/
public static function csvEscape(array $data, $delimiter = ',')
{
$buffer = fopen('php://temp', 'r+');
fputcsv($buffer, $data, $delimiter);
rewind($buffer);
$csv = fgets($buffer);
fclose($buffer);
return rtrim($csv);
}
/**
* Return a specific named annotation for this command.
*
* @param string|array $data The data to convert to an array.
* @return array
*/
public static function toList($data)
{
if (!is_array($data)) {
return str_getcsv($data);
}
return $data;
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
/**
* Hold the tag definition for one tag in a DocBlock.
*
* The tag can be sliced into the following forms:
* - "@tag content"
* - "@tag word description"
* - "@tag $variable description"
* - "@tag word $variable description"
*/
class DocblockTag
{
/** @var string Name of the tag */
protected $tag;
/** @var string|null Contents of the tag. */
protected $content;
const TAG_REGEX = '@(?P<tag>[^\s$]+)[\s]*';
const VARIABLE_REGEX = '\\$(?P<variable>[^\s$]+)[\s]*';
const VARIABLE_OR_WORD_REGEX = '\\$?(?P<variable>[^\s$]+)[\s]*';
const TYPE_REGEX = '(?P<type>[^\s$]+)[\s]*';
const WORD_REGEX = '(?P<word>[^\s$]+)[\s]*';
const DESCRIPTION_REGEX = '(?P<description>.*)';
const IS_TAG_REGEX = '/^[*\s]*@/';
/**
* Check if the provided string begins with a tag
* @param string $subject
* @return bool
*/
public static function isTag($subject)
{
return preg_match(self::IS_TAG_REGEX, $subject);
}
/**
* Use a regular expression to separate the tag from the content.
*
* @param string $subject
* @param string[] &$matches Sets $matches['tag'] and $matches['description']
* @return bool
*/
public static function splitTagAndContent($subject, &$matches)
{
$regex = '/' . self::TAG_REGEX . self::DESCRIPTION_REGEX . '/s';
return preg_match($regex, $subject, $matches);
}
/**
* DockblockTag constructor
*/
public function __construct($tag, $content = null)
{
$this->tag = $tag;
$this->content = $content;
}
/**
* Add more content onto a tag during parsing.
*/
public function appendContent($line)
{
$this->content .= "\n$line";
}
/**
* Return the tag - e.g. "@foo description" returns 'foo'
*
* @return string
*/
public function getTag()
{
return $this->tag;
}
/**
* Return the content portion of the tag - e.g. "@foo bar baz boz" returns
* "bar baz boz"
*
* @return string
*/
public function getContent()
{
return $this->content;
}
/**
* Convert tag back into a string.
*/
public function __toString()
{
return '@' . $this->getTag() . ' ' . $this->getContent();
}
/**
* Determine if tag is one of:
* - "@tag variable description"
* - "@tag $variable description"
* - "@tag type $variable description"
*
* @param string $subject
* @param string[] &$matches Sets $matches['variable'] and
* $matches['description']; might set $matches['type'].
* @return bool
*/
public function hasVariable(&$matches)
{
return
$this->hasTypeVariableAndDescription($matches) ||
$this->hasVariableAndDescription($matches);
}
/**
* Determine if tag is "@tag $variable description"
* @param string $subject
* @param string[] &$matches Sets $matches['variable'] and
* $matches['description']
* @return bool
*/
public function hasVariableAndDescription(&$matches)
{
$regex = '/^\s*' . self::VARIABLE_OR_WORD_REGEX . self::DESCRIPTION_REGEX . '/s';
return preg_match($regex, $this->getContent(), $matches);
}
/**
* Determine if tag is "@tag type $variable description"
*
* @param string $subject
* @param string[] &$matches Sets $matches['variable'],
* $matches['description'] and $matches['type'].
* @return bool
*/
public function hasTypeVariableAndDescription(&$matches)
{
$regex = '/^\s*' . self::TYPE_REGEX . self::VARIABLE_REGEX . self::DESCRIPTION_REGEX . '/s';
return preg_match($regex, $this->getContent(), $matches);
}
/**
* Determine if tag is "@tag word description"
* @param string $subject
* @param string[] &$matches Sets $matches['word'] and
* $matches['description']
* @return bool
*/
public function hasWordAndDescription(&$matches)
{
$regex = '/^\s*' . self::WORD_REGEX . self::DESCRIPTION_REGEX . '/s';
return preg_match($regex, $this->getContent(), $matches);
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
class FullyQualifiedClassCache
{
protected $classCache = [];
protected $namespaceCache = [];
public function qualify($filename, $className)
{
$this->primeCache($filename, $className);
return $this->cached($filename, $className);
}
protected function cached($filename, $className)
{
return isset($this->classCache[$filename][$className]) ? $this->classCache[$filename][$className] : $className;
}
protected function primeCache($filename, $className)
{
// If the cache has already been primed, do no further work
if (isset($this->namespaceCache[$filename])) {
return false;
}
$handle = fopen($filename, "r");
if (!$handle) {
return false;
}
$namespaceName = $this->primeNamespaceCache($filename, $handle);
$this->primeUseCache($filename, $handle);
// If there is no 'use' statement for the className, then
// generate an effective classname from the namespace
if (!isset($this->classCache[$filename][$className])) {
$this->classCache[$filename][$className] = $namespaceName . '\\' . $className;
}
fclose($handle);
}
protected function primeNamespaceCache($filename, $handle)
{
$namespaceName = $this->readNamespace($handle);
if (!$namespaceName) {
return false;
}
$this->namespaceCache[$filename] = $namespaceName;
return $namespaceName;
}
protected function primeUseCache($filename, $handle)
{
$usedClasses = $this->readUseStatements($handle);
if (empty($usedClasses)) {
return false;
}
$this->classCache[$filename] = $usedClasses;
}
protected function readNamespace($handle)
{
$namespaceRegex = '#^\s*namespace\s+#';
$line = $this->readNextRelevantLine($handle);
if (!$line || !preg_match($namespaceRegex, $line)) {
return false;
}
$namespaceName = preg_replace($namespaceRegex, '', $line);
$namespaceName = rtrim($namespaceName, ';');
return $namespaceName;
}
protected function readUseStatements($handle)
{
$useRegex = '#^\s*use\s+#';
$result = [];
while (true) {
$line = $this->readNextRelevantLine($handle);
if (!$line || !preg_match($useRegex, $line)) {
return $result;
}
$usedClass = preg_replace($useRegex, '', $line);
$usedClass = rtrim($usedClass, ';');
$unqualifiedClass = preg_replace('#.*\\\\#', '', $usedClass);
// If this is an aliased class, 'use \Foo\Bar as Baz', then adjust
if (strpos($usedClass, ' as ')) {
$unqualifiedClass = preg_replace('#.*\sas\s+#', '', $usedClass);
$usedClass = preg_replace('#\s+as\s+#', '', $usedClass);
}
$result[$unqualifiedClass] = $usedClass;
}
}
protected function readNextRelevantLine($handle)
{
while (($line = fgets($handle)) !== false) {
if (preg_match('#^\s*\w#', $line)) {
return trim($line);
}
}
return false;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Consolidation\AnnotatedCommand\Parser\Internal;
/**
* Hold some state. Collect tags.
*/
class TagFactory
{
/** @var DocblockTag|null Current tag */
protected $current;
/** @var DocblockTag[] All tag */
protected $tags;
/**
* DocblockTag constructor
*/
public function __construct()
{
$this->current = null;
$this->tags = [];
}
public function parseLine($line)
{
if (DocblockTag::isTag($line)) {
return $this->createTag($line);
}
if (empty($line)) {
return $this->storeCurrentTag();
}
return $this->accumulateContent($line);
}
public function getTags()
{
$this->storeCurrentTag();
return $this->tags;
}
protected function createTag($line)
{
DocblockTag::splitTagAndContent($line, $matches);
$this->storeCurrentTag();
$this->current = new DocblockTag($matches['tag'], $matches['description']);
return true;
}
protected function storeCurrentTag()
{
if (!$this->current) {
return false;
}
$this->tags[] = $this->current;
$this->current = false;
return true;
}
protected function accumulateContent($line)
{
if (!$this->current) {
return false;
}
$this->current->appendContent($line);
return true;
}
}