--- /dev/null
+<?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.
+ */
+abstract class AbstractCommandDocBlockParser
+{
+ /**
+ * @var CommandInfo
+ */
+ protected $commandInfo;
+
+ /**
+ * @var \ReflectionMethod
+ */
+ protected $reflection;
+
+ /**
+ * @var string
+ */
+ protected $optionParamName;
+
+ /**
+ * @var array
+ */
+ protected $tagProcessors = [
+ 'command' => 'processCommandTag',
+ 'name' => 'processCommandTag',
+ 'arg' => 'processArgumentTag',
+ 'param' => 'processParamTag',
+ 'return' => 'processReturnTag',
+ 'option' => 'processOptionTag',
+ 'default' => 'processDefaultTag',
+ 'aliases' => 'processAliases',
+ 'usage' => 'processUsageTag',
+ 'description' => 'processAlternateDescriptionTag',
+ 'desc' => 'processAlternateDescriptionTag',
+ ];
+
+ public function __construct(CommandInfo $commandInfo, \ReflectionMethod $reflection)
+ {
+ $this->commandInfo = $commandInfo;
+ $this->reflection = $reflection;
+ }
+
+ protected function processAllTags($phpdoc)
+ {
+ // Iterate over all of the tags, and process them as necessary.
+ foreach ($phpdoc->getTags() as $tag) {
+ $processFn = [$this, 'processGenericTag'];
+ if (array_key_exists($tag->getName(), $this->tagProcessors)) {
+ $processFn = [$this, $this->tagProcessors[$tag->getName()]];
+ }
+ $processFn($tag);
+ }
+ }
+
+ abstract protected function getTagContents($tag);
+
+ /**
+ * Parse the docBlock comment for this command, and set the
+ * fields of this class with the data thereby obtained.
+ */
+ abstract public function parse();
+
+ /**
+ * Save any tag that we do not explicitly recognize in the
+ * 'otherAnnotations' map.
+ */
+ protected function processGenericTag($tag)
+ {
+ $this->commandInfo->addAnnotation($tag->getName(), $this->getTagContents($tag));
+ }
+
+ /**
+ * Set the name of the command from a @command or @name annotation.
+ */
+ protected function processCommandTag($tag)
+ {
+ $commandName = $this->getTagContents($tag);
+ $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->getName(), $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($this->getTagContents($tag));
+ }
+
+ /**
+ * Store the data from a @arg annotation in our argument descriptions.
+ */
+ protected function processArgumentTag($tag)
+ {
+ if (!$this->pregMatchNameAndDescription((string)$tag->getDescription(), $match)) {
+ return;
+ }
+ $this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $match);
+ }
+
+ /**
+ * Store the data from an @option annotation in our option descriptions.
+ */
+ protected function processOptionTag($tag)
+ {
+ if (!$this->pregMatchOptionNameAndDescription((string)$tag->getDescription(), $match)) {
+ return;
+ }
+ $this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $match);
+ }
+
+ protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $nameAndDescription)
+ {
+ $variableName = $this->commandInfo->findMatchingOption($nameAndDescription['name']);
+ $desc = $nameAndDescription['description'];
+ $description = static::removeLineBreaks($desc);
+ $set->add($variableName, $description);
+ }
+
+ /**
+ * Store the data from a @default annotation in our argument or option store,
+ * as appropriate.
+ */
+ protected function processDefaultTag($tag)
+ {
+ if (!$this->pregMatchNameAndDescription((string)$tag->getDescription(), $match)) {
+ return;
+ }
+ $variableName = $match['name'];
+ $defaultValue = $this->interpretDefaultValue($match['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", $this->getTagContents($tag));
+ $usage = array_shift($lines);
+ $description = static::removeLineBreaks(implode("\n", $lines));
+
+ $this->commandInfo->setExampleUsage($usage, $description);
+ }
+
+ /**
+ * Process the comma-separated list of aliases
+ */
+ protected function processAliases($tag)
+ {
+ $this->commandInfo->setAliases((string)$tag->getDescription());
+ }
+
+ 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;
+ }
+
+ /**
+ * Store the data from a @param annotation in our argument descriptions.
+ */
+ protected function processParamTag($tag)
+ {
+ $variableName = $tag->getVariableName();
+ $variableName = str_replace('$', '', $variableName);
+ $description = static::removeLineBreaks((string)$tag->getDescription());
+ if ($variableName == $this->optionParamName()) {
+ return;
+ }
+ $this->commandInfo->arguments()->add($variableName, $description);
+ }
+
+ /**
+ * Store the data from a @return annotation in our argument descriptions.
+ */
+ abstract protected function processReturnTag($tag);
+
+ 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 docblock description in the form "$variable description",
+ * return the variable name and description via the 'match' parameter.
+ */
+ protected function pregMatchNameAndDescription($source, &$match)
+ {
+ $nameRegEx = '\\$(?P<name>[^ \t]+)[ \t]+';
+ $descriptionRegEx = '(?P<description>.*)';
+ $optionRegEx = "/{$nameRegEx}{$descriptionRegEx}/s";
+
+ return preg_match($optionRegEx, $source, $match);
+ }
+
+ /**
+ * Given a docblock description in the form "$variable description",
+ * return the variable name and description via the 'match' parameter.
+ */
+ protected function pregMatchOptionNameAndDescription($source, &$match)
+ {
+ // Strip type and $ from the text before the @option name, if present.
+ $source = preg_replace('/^[a-zA-Z]* ?\\$/', '', $source);
+ $nameRegEx = '(?P<name>[^ \t]+)[ \t]+';
+ $descriptionRegEx = '(?P<description>.*)';
+ $optionRegEx = "/{$nameRegEx}{$descriptionRegEx}/s";
+
+ return preg_match($optionRegEx, $source, $match);
+ }
+
+ /**
+ * 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));
+ }
+}