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