2 namespace Consolidation\AnnotatedCommand\Parser\Internal;
4 use Consolidation\AnnotatedCommand\Parser\CommandInfo;
5 use Consolidation\AnnotatedCommand\Parser\DefaultsWithDescriptions;
8 * Given a class and method name, parse the annotations in the
9 * DocBlock comment, and provide accessor methods for all of
10 * the elements that are needed to create an annotated Command.
12 class BespokeDocBlockParser
19 protected $tagProcessors = [
20 'command' => 'processCommandTag',
21 'name' => 'processCommandTag',
22 'arg' => 'processArgumentTag',
23 'param' => 'processArgumentTag',
24 'return' => 'processReturnTag',
25 'option' => 'processOptionTag',
26 'default' => 'processDefaultTag',
27 'aliases' => 'processAliases',
28 'usage' => 'processUsageTag',
29 'description' => 'processAlternateDescriptionTag',
30 'desc' => 'processAlternateDescriptionTag',
33 public function __construct(CommandInfo $commandInfo, \ReflectionMethod $reflection, $fqcnCache = null)
35 $this->commandInfo = $commandInfo;
36 $this->reflection = $reflection;
37 $this->fqcnCache = $fqcnCache ?: new FullyQualifiedClassCache();
41 * Parse the docBlock comment for this command, and set the
42 * fields of this class with the data thereby obtained.
44 public function parse()
46 $doc = $this->reflection->getDocComment();
47 $this->parseDocBlock($doc);
51 * Save any tag that we do not explicitly recognize in the
52 * 'otherAnnotations' map.
54 protected function processGenericTag($tag)
56 $this->commandInfo->addAnnotation($tag->getTag(), $tag->getContent());
60 * Set the name of the command from a @command or @name annotation.
62 protected function processCommandTag($tag)
64 if (!$tag->hasWordAndDescription($matches)) {
65 throw new \Exception('Could not determine command name from tag ' . (string)$tag);
67 $commandName = $matches['word'];
68 $this->commandInfo->setName($commandName);
69 // We also store the name in the 'other annotations' so that is is
70 // possible to determine if the method had a @command annotation.
71 $this->commandInfo->addAnnotation($tag->getTag(), $commandName);
75 * The @description and @desc annotations may be used in
76 * place of the synopsis (which we call 'description').
77 * This is discouraged.
81 protected function processAlternateDescriptionTag($tag)
83 $this->commandInfo->setDescription($tag->getContent());
87 * Store the data from a @arg annotation in our argument descriptions.
89 protected function processArgumentTag($tag)
91 if (!$tag->hasVariable($matches)) {
92 throw new \Exception('Could not determine argument name from tag ' . (string)$tag);
94 if ($matches['variable'] == $this->optionParamName()) {
97 $this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $matches['variable'], $matches['description']);
101 * Store the data from an @option annotation in our option descriptions.
103 protected function processOptionTag($tag)
105 if (!$tag->hasVariable($matches)) {
106 throw new \Exception('Could not determine option name from tag ' . (string)$tag);
108 $this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $matches['variable'], $matches['description']);
111 protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $name, $description)
113 $variableName = $this->commandInfo->findMatchingOption($name);
114 $description = static::removeLineBreaks($description);
115 $set->add($variableName, $description);
119 * Store the data from a @default annotation in our argument or option store,
122 protected function processDefaultTag($tag)
124 if (!$tag->hasVariable($matches)) {
125 throw new \Exception('Could not determine parameter name for default value from tag ' . (string)$tag);
127 $variableName = $matches['variable'];
128 $defaultValue = $this->interpretDefaultValue($matches['description']);
129 if ($this->commandInfo->arguments()->exists($variableName)) {
130 $this->commandInfo->arguments()->setDefaultValue($variableName, $defaultValue);
133 $variableName = $this->commandInfo->findMatchingOption($variableName);
134 if ($this->commandInfo->options()->exists($variableName)) {
135 $this->commandInfo->options()->setDefaultValue($variableName, $defaultValue);
140 * Store the data from a @usage annotation in our example usage list.
142 protected function processUsageTag($tag)
144 $lines = explode("\n", $tag->getContent());
145 $usage = trim(array_shift($lines));
146 $description = static::removeLineBreaks(implode("\n", array_map(function ($line) {
150 $this->commandInfo->setExampleUsage($usage, $description);
154 * Process the comma-separated list of aliases
156 protected function processAliases($tag)
158 $this->commandInfo->setAliases((string)$tag->getContent());
162 * Store the data from a @return annotation in our argument descriptions.
164 protected function processReturnTag($tag)
166 // The return type might be a variable -- '$this'. It will
167 // usually be a type, like RowsOfFields, or \Namespace\RowsOfFields.
168 if (!$tag->hasVariableAndDescription($matches)) {
169 throw new \Exception('Could not determine return type from tag ' . (string)$tag);
171 // Look at namespace and `use` statments to make returnType a fqdn
172 $returnType = $matches['variable'];
173 $returnType = $this->findFullyQualifiedClass($returnType);
174 $this->commandInfo->setReturnType($returnType);
177 protected function findFullyQualifiedClass($className)
179 if (strpos($className, '\\') !== false) {
183 return $this->fqcnCache->qualify($this->reflection->getFileName(), $className);
186 private function parseDocBlock($doc)
188 // Remove the leading /** and the trailing */
189 $doc = preg_replace('#^\s*/\*+\s*#', '', $doc);
190 $doc = preg_replace('#\s*\*+/\s*#', '', $doc);
192 // Nothing left? Exit.
197 $tagFactory = new TagFactory();
200 foreach (explode("\n", $doc) as $row) {
201 // Remove trailing whitespace and leading space + '*'s
203 $row = preg_replace('#^[ \t]*\**#', '', $row);
205 if (!$tagFactory->parseLine($row)) {
210 $this->processDescriptionAndHelp($lines);
211 $this->processAllTags($tagFactory->getTags());
214 protected function processDescriptionAndHelp($lines)
216 // Trim all of the lines individually.
225 // Everything up to the first blank line goes in the description.
226 $description = array_shift($lines);
227 while ($this->nextLineIsNotEmpty($lines)) {
228 $description .= ' ' . array_shift($lines);
231 // Everything else goes in the help.
232 $help = trim(implode("\n", $lines));
234 $this->commandInfo->setDescription($description);
235 $this->commandInfo->setHelp($help);
238 protected function nextLineIsNotEmpty($lines)
244 $nextLine = trim($lines[0]);
245 return !empty($nextLine);
248 protected function processAllTags($tags)
250 // Iterate over all of the tags, and process them as necessary.
251 foreach ($tags as $tag) {
252 $processFn = [$this, 'processGenericTag'];
253 if (array_key_exists($tag->getTag(), $this->tagProcessors)) {
254 $processFn = [$this, $this->tagProcessors[$tag->getTag()]];
260 protected function lastParameterName()
262 $params = $this->commandInfo->getParameters();
263 $param = end($params);
271 * Return the name of the last parameter if it holds the options.
273 public function optionParamName()
275 // Remember the name of the last parameter, if it holds the options.
276 // We will use this information to ignore @param annotations for the options.
277 if (!isset($this->optionParamName)) {
278 $this->optionParamName = '';
279 $options = $this->commandInfo->options();
280 if (!$options->isEmpty()) {
281 $this->optionParamName = $this->lastParameterName();
285 return $this->optionParamName;
288 protected function interpretDefaultValue($defaultValue)
297 foreach ($defaults as $defaultName => $defaultTypedValue) {
298 if ($defaultValue == $defaultName) {
299 return $defaultTypedValue;
302 return $defaultValue;
306 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
307 * convert the data into the last of these forms.
309 protected static function convertListToCommaSeparated($text)
311 return preg_replace('#[ \t\n\r,]+#', ',', $text);
315 * Take a multiline description and convert it into a single
316 * long unbroken line.
318 protected static function removeLineBreaks($text)
320 return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));