Chris@0
|
1 <?php
|
Chris@0
|
2 namespace Consolidation\AnnotatedCommand\Parser\Internal;
|
Chris@0
|
3
|
Chris@0
|
4 use Consolidation\AnnotatedCommand\Parser\CommandInfo;
|
Chris@0
|
5 use Consolidation\AnnotatedCommand\Parser\DefaultsWithDescriptions;
|
Chris@0
|
6
|
Chris@0
|
7 /**
|
Chris@0
|
8 * Given a class and method name, parse the annotations in the
|
Chris@0
|
9 * DocBlock comment, and provide accessor methods for all of
|
Chris@0
|
10 * the elements that are needed to create an annotated Command.
|
Chris@0
|
11 */
|
Chris@0
|
12 class BespokeDocBlockParser
|
Chris@0
|
13 {
|
Chris@0
|
14 protected $fqcnCache;
|
Chris@0
|
15
|
Chris@0
|
16 /**
|
Chris@0
|
17 * @var array
|
Chris@0
|
18 */
|
Chris@0
|
19 protected $tagProcessors = [
|
Chris@0
|
20 'command' => 'processCommandTag',
|
Chris@0
|
21 'name' => 'processCommandTag',
|
Chris@0
|
22 'arg' => 'processArgumentTag',
|
Chris@17
|
23 'param' => 'processParamTag',
|
Chris@0
|
24 'return' => 'processReturnTag',
|
Chris@0
|
25 'option' => 'processOptionTag',
|
Chris@0
|
26 'default' => 'processDefaultTag',
|
Chris@0
|
27 'aliases' => 'processAliases',
|
Chris@0
|
28 'usage' => 'processUsageTag',
|
Chris@0
|
29 'description' => 'processAlternateDescriptionTag',
|
Chris@0
|
30 'desc' => 'processAlternateDescriptionTag',
|
Chris@0
|
31 ];
|
Chris@0
|
32
|
Chris@0
|
33 public function __construct(CommandInfo $commandInfo, \ReflectionMethod $reflection, $fqcnCache = null)
|
Chris@0
|
34 {
|
Chris@0
|
35 $this->commandInfo = $commandInfo;
|
Chris@0
|
36 $this->reflection = $reflection;
|
Chris@0
|
37 $this->fqcnCache = $fqcnCache ?: new FullyQualifiedClassCache();
|
Chris@0
|
38 }
|
Chris@0
|
39
|
Chris@0
|
40 /**
|
Chris@0
|
41 * Parse the docBlock comment for this command, and set the
|
Chris@0
|
42 * fields of this class with the data thereby obtained.
|
Chris@0
|
43 */
|
Chris@0
|
44 public function parse()
|
Chris@0
|
45 {
|
Chris@0
|
46 $doc = $this->reflection->getDocComment();
|
Chris@0
|
47 $this->parseDocBlock($doc);
|
Chris@0
|
48 }
|
Chris@0
|
49
|
Chris@0
|
50 /**
|
Chris@0
|
51 * Save any tag that we do not explicitly recognize in the
|
Chris@0
|
52 * 'otherAnnotations' map.
|
Chris@0
|
53 */
|
Chris@0
|
54 protected function processGenericTag($tag)
|
Chris@0
|
55 {
|
Chris@0
|
56 $this->commandInfo->addAnnotation($tag->getTag(), $tag->getContent());
|
Chris@0
|
57 }
|
Chris@0
|
58
|
Chris@0
|
59 /**
|
Chris@0
|
60 * Set the name of the command from a @command or @name annotation.
|
Chris@0
|
61 */
|
Chris@0
|
62 protected function processCommandTag($tag)
|
Chris@0
|
63 {
|
Chris@0
|
64 if (!$tag->hasWordAndDescription($matches)) {
|
Chris@0
|
65 throw new \Exception('Could not determine command name from tag ' . (string)$tag);
|
Chris@0
|
66 }
|
Chris@0
|
67 $commandName = $matches['word'];
|
Chris@0
|
68 $this->commandInfo->setName($commandName);
|
Chris@0
|
69 // We also store the name in the 'other annotations' so that is is
|
Chris@0
|
70 // possible to determine if the method had a @command annotation.
|
Chris@0
|
71 $this->commandInfo->addAnnotation($tag->getTag(), $commandName);
|
Chris@0
|
72 }
|
Chris@0
|
73
|
Chris@0
|
74 /**
|
Chris@0
|
75 * The @description and @desc annotations may be used in
|
Chris@0
|
76 * place of the synopsis (which we call 'description').
|
Chris@0
|
77 * This is discouraged.
|
Chris@0
|
78 *
|
Chris@0
|
79 * @deprecated
|
Chris@0
|
80 */
|
Chris@0
|
81 protected function processAlternateDescriptionTag($tag)
|
Chris@0
|
82 {
|
Chris@0
|
83 $this->commandInfo->setDescription($tag->getContent());
|
Chris@0
|
84 }
|
Chris@0
|
85
|
Chris@0
|
86 /**
|
Chris@17
|
87 * Store the data from a @param annotation in our argument descriptions.
|
Chris@17
|
88 */
|
Chris@17
|
89 protected function processParamTag($tag)
|
Chris@17
|
90 {
|
Chris@17
|
91 if ($tag->hasTypeVariableAndDescription($matches)) {
|
Chris@17
|
92 if ($this->ignoredParamType($matches['type'])) {
|
Chris@17
|
93 return;
|
Chris@17
|
94 }
|
Chris@17
|
95 }
|
Chris@17
|
96 return $this->processArgumentTag($tag);
|
Chris@17
|
97 }
|
Chris@17
|
98
|
Chris@17
|
99 protected function ignoredParamType($paramType)
|
Chris@17
|
100 {
|
Chris@17
|
101 // TODO: We should really only allow a couple of types here,
|
Chris@17
|
102 // e.g. 'string', 'array', 'bool'. Blacklist things we do not
|
Chris@17
|
103 // want for now to avoid breaking commands with weird types.
|
Chris@17
|
104 // Fix in the next major version.
|
Chris@17
|
105 //
|
Chris@17
|
106 // This works:
|
Chris@17
|
107 // return !in_array($paramType, ['string', 'array', 'integer', 'bool']);
|
Chris@17
|
108 return preg_match('#(InputInterface|OutputInterface)$#', $paramType);
|
Chris@17
|
109 }
|
Chris@17
|
110
|
Chris@17
|
111 /**
|
Chris@0
|
112 * Store the data from a @arg annotation in our argument descriptions.
|
Chris@0
|
113 */
|
Chris@0
|
114 protected function processArgumentTag($tag)
|
Chris@0
|
115 {
|
Chris@0
|
116 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
117 throw new \Exception('Could not determine argument name from tag ' . (string)$tag);
|
Chris@0
|
118 }
|
Chris@0
|
119 if ($matches['variable'] == $this->optionParamName()) {
|
Chris@0
|
120 return;
|
Chris@0
|
121 }
|
Chris@0
|
122 $this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $matches['variable'], $matches['description']);
|
Chris@0
|
123 }
|
Chris@0
|
124
|
Chris@0
|
125 /**
|
Chris@0
|
126 * Store the data from an @option annotation in our option descriptions.
|
Chris@0
|
127 */
|
Chris@0
|
128 protected function processOptionTag($tag)
|
Chris@0
|
129 {
|
Chris@0
|
130 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
131 throw new \Exception('Could not determine option name from tag ' . (string)$tag);
|
Chris@0
|
132 }
|
Chris@0
|
133 $this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $matches['variable'], $matches['description']);
|
Chris@0
|
134 }
|
Chris@0
|
135
|
Chris@0
|
136 protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $name, $description)
|
Chris@0
|
137 {
|
Chris@0
|
138 $variableName = $this->commandInfo->findMatchingOption($name);
|
Chris@0
|
139 $description = static::removeLineBreaks($description);
|
Chris@18
|
140 list($description, $defaultValue) = $this->splitOutDefault($description);
|
Chris@0
|
141 $set->add($variableName, $description);
|
Chris@18
|
142 if ($defaultValue !== null) {
|
Chris@18
|
143 $set->setDefaultValue($variableName, $defaultValue);
|
Chris@18
|
144 }
|
Chris@18
|
145 }
|
Chris@18
|
146
|
Chris@18
|
147 protected function splitOutDefault($description)
|
Chris@18
|
148 {
|
Chris@18
|
149 if (!preg_match('#(.*)(Default: *)(.*)#', trim($description), $matches)) {
|
Chris@18
|
150 return [$description, null];
|
Chris@18
|
151 }
|
Chris@18
|
152
|
Chris@18
|
153 return [trim($matches[1]), $this->interpretDefaultValue(trim($matches[3]))];
|
Chris@0
|
154 }
|
Chris@0
|
155
|
Chris@0
|
156 /**
|
Chris@0
|
157 * Store the data from a @default annotation in our argument or option store,
|
Chris@0
|
158 * as appropriate.
|
Chris@0
|
159 */
|
Chris@0
|
160 protected function processDefaultTag($tag)
|
Chris@0
|
161 {
|
Chris@0
|
162 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
163 throw new \Exception('Could not determine parameter name for default value from tag ' . (string)$tag);
|
Chris@0
|
164 }
|
Chris@0
|
165 $variableName = $matches['variable'];
|
Chris@0
|
166 $defaultValue = $this->interpretDefaultValue($matches['description']);
|
Chris@0
|
167 if ($this->commandInfo->arguments()->exists($variableName)) {
|
Chris@0
|
168 $this->commandInfo->arguments()->setDefaultValue($variableName, $defaultValue);
|
Chris@0
|
169 return;
|
Chris@0
|
170 }
|
Chris@0
|
171 $variableName = $this->commandInfo->findMatchingOption($variableName);
|
Chris@0
|
172 if ($this->commandInfo->options()->exists($variableName)) {
|
Chris@0
|
173 $this->commandInfo->options()->setDefaultValue($variableName, $defaultValue);
|
Chris@0
|
174 }
|
Chris@0
|
175 }
|
Chris@0
|
176
|
Chris@0
|
177 /**
|
Chris@0
|
178 * Store the data from a @usage annotation in our example usage list.
|
Chris@0
|
179 */
|
Chris@0
|
180 protected function processUsageTag($tag)
|
Chris@0
|
181 {
|
Chris@0
|
182 $lines = explode("\n", $tag->getContent());
|
Chris@0
|
183 $usage = trim(array_shift($lines));
|
Chris@0
|
184 $description = static::removeLineBreaks(implode("\n", array_map(function ($line) {
|
Chris@0
|
185 return trim($line);
|
Chris@0
|
186 }, $lines)));
|
Chris@0
|
187
|
Chris@0
|
188 $this->commandInfo->setExampleUsage($usage, $description);
|
Chris@0
|
189 }
|
Chris@0
|
190
|
Chris@0
|
191 /**
|
Chris@0
|
192 * Process the comma-separated list of aliases
|
Chris@0
|
193 */
|
Chris@0
|
194 protected function processAliases($tag)
|
Chris@0
|
195 {
|
Chris@0
|
196 $this->commandInfo->setAliases((string)$tag->getContent());
|
Chris@0
|
197 }
|
Chris@0
|
198
|
Chris@0
|
199 /**
|
Chris@0
|
200 * Store the data from a @return annotation in our argument descriptions.
|
Chris@0
|
201 */
|
Chris@0
|
202 protected function processReturnTag($tag)
|
Chris@0
|
203 {
|
Chris@0
|
204 // The return type might be a variable -- '$this'. It will
|
Chris@0
|
205 // usually be a type, like RowsOfFields, or \Namespace\RowsOfFields.
|
Chris@0
|
206 if (!$tag->hasVariableAndDescription($matches)) {
|
Chris@0
|
207 throw new \Exception('Could not determine return type from tag ' . (string)$tag);
|
Chris@0
|
208 }
|
Chris@0
|
209 // Look at namespace and `use` statments to make returnType a fqdn
|
Chris@0
|
210 $returnType = $matches['variable'];
|
Chris@0
|
211 $returnType = $this->findFullyQualifiedClass($returnType);
|
Chris@0
|
212 $this->commandInfo->setReturnType($returnType);
|
Chris@0
|
213 }
|
Chris@0
|
214
|
Chris@0
|
215 protected function findFullyQualifiedClass($className)
|
Chris@0
|
216 {
|
Chris@0
|
217 if (strpos($className, '\\') !== false) {
|
Chris@0
|
218 return $className;
|
Chris@0
|
219 }
|
Chris@0
|
220
|
Chris@0
|
221 return $this->fqcnCache->qualify($this->reflection->getFileName(), $className);
|
Chris@0
|
222 }
|
Chris@0
|
223
|
Chris@0
|
224 private function parseDocBlock($doc)
|
Chris@0
|
225 {
|
Chris@0
|
226 // Remove the leading /** and the trailing */
|
Chris@0
|
227 $doc = preg_replace('#^\s*/\*+\s*#', '', $doc);
|
Chris@0
|
228 $doc = preg_replace('#\s*\*+/\s*#', '', $doc);
|
Chris@0
|
229
|
Chris@0
|
230 // Nothing left? Exit.
|
Chris@0
|
231 if (empty($doc)) {
|
Chris@0
|
232 return;
|
Chris@0
|
233 }
|
Chris@0
|
234
|
Chris@0
|
235 $tagFactory = new TagFactory();
|
Chris@0
|
236 $lines = [];
|
Chris@0
|
237
|
Chris@0
|
238 foreach (explode("\n", $doc) as $row) {
|
Chris@0
|
239 // Remove trailing whitespace and leading space + '*'s
|
Chris@0
|
240 $row = rtrim($row);
|
Chris@0
|
241 $row = preg_replace('#^[ \t]*\**#', '', $row);
|
Chris@0
|
242
|
Chris@0
|
243 if (!$tagFactory->parseLine($row)) {
|
Chris@0
|
244 $lines[] = $row;
|
Chris@0
|
245 }
|
Chris@0
|
246 }
|
Chris@0
|
247
|
Chris@0
|
248 $this->processDescriptionAndHelp($lines);
|
Chris@0
|
249 $this->processAllTags($tagFactory->getTags());
|
Chris@0
|
250 }
|
Chris@0
|
251
|
Chris@0
|
252 protected function processDescriptionAndHelp($lines)
|
Chris@0
|
253 {
|
Chris@0
|
254 // Trim all of the lines individually.
|
Chris@0
|
255 $lines =
|
Chris@0
|
256 array_map(
|
Chris@0
|
257 function ($line) {
|
Chris@0
|
258 return trim($line);
|
Chris@0
|
259 },
|
Chris@0
|
260 $lines
|
Chris@0
|
261 );
|
Chris@0
|
262
|
Chris@0
|
263 // Everything up to the first blank line goes in the description.
|
Chris@0
|
264 $description = array_shift($lines);
|
Chris@0
|
265 while ($this->nextLineIsNotEmpty($lines)) {
|
Chris@0
|
266 $description .= ' ' . array_shift($lines);
|
Chris@0
|
267 }
|
Chris@0
|
268
|
Chris@0
|
269 // Everything else goes in the help.
|
Chris@0
|
270 $help = trim(implode("\n", $lines));
|
Chris@0
|
271
|
Chris@0
|
272 $this->commandInfo->setDescription($description);
|
Chris@0
|
273 $this->commandInfo->setHelp($help);
|
Chris@0
|
274 }
|
Chris@0
|
275
|
Chris@0
|
276 protected function nextLineIsNotEmpty($lines)
|
Chris@0
|
277 {
|
Chris@0
|
278 if (empty($lines)) {
|
Chris@0
|
279 return false;
|
Chris@0
|
280 }
|
Chris@0
|
281
|
Chris@0
|
282 $nextLine = trim($lines[0]);
|
Chris@0
|
283 return !empty($nextLine);
|
Chris@0
|
284 }
|
Chris@0
|
285
|
Chris@0
|
286 protected function processAllTags($tags)
|
Chris@0
|
287 {
|
Chris@0
|
288 // Iterate over all of the tags, and process them as necessary.
|
Chris@0
|
289 foreach ($tags as $tag) {
|
Chris@0
|
290 $processFn = [$this, 'processGenericTag'];
|
Chris@0
|
291 if (array_key_exists($tag->getTag(), $this->tagProcessors)) {
|
Chris@0
|
292 $processFn = [$this, $this->tagProcessors[$tag->getTag()]];
|
Chris@0
|
293 }
|
Chris@0
|
294 $processFn($tag);
|
Chris@0
|
295 }
|
Chris@0
|
296 }
|
Chris@0
|
297
|
Chris@0
|
298 protected function lastParameterName()
|
Chris@0
|
299 {
|
Chris@0
|
300 $params = $this->commandInfo->getParameters();
|
Chris@0
|
301 $param = end($params);
|
Chris@0
|
302 if (!$param) {
|
Chris@0
|
303 return '';
|
Chris@0
|
304 }
|
Chris@0
|
305 return $param->name;
|
Chris@0
|
306 }
|
Chris@0
|
307
|
Chris@0
|
308 /**
|
Chris@0
|
309 * Return the name of the last parameter if it holds the options.
|
Chris@0
|
310 */
|
Chris@0
|
311 public function optionParamName()
|
Chris@0
|
312 {
|
Chris@0
|
313 // Remember the name of the last parameter, if it holds the options.
|
Chris@0
|
314 // We will use this information to ignore @param annotations for the options.
|
Chris@0
|
315 if (!isset($this->optionParamName)) {
|
Chris@0
|
316 $this->optionParamName = '';
|
Chris@0
|
317 $options = $this->commandInfo->options();
|
Chris@0
|
318 if (!$options->isEmpty()) {
|
Chris@0
|
319 $this->optionParamName = $this->lastParameterName();
|
Chris@0
|
320 }
|
Chris@0
|
321 }
|
Chris@0
|
322
|
Chris@0
|
323 return $this->optionParamName;
|
Chris@0
|
324 }
|
Chris@0
|
325
|
Chris@0
|
326 protected function interpretDefaultValue($defaultValue)
|
Chris@0
|
327 {
|
Chris@0
|
328 $defaults = [
|
Chris@0
|
329 'null' => null,
|
Chris@0
|
330 'true' => true,
|
Chris@0
|
331 'false' => false,
|
Chris@0
|
332 "''" => '',
|
Chris@0
|
333 '[]' => [],
|
Chris@0
|
334 ];
|
Chris@0
|
335 foreach ($defaults as $defaultName => $defaultTypedValue) {
|
Chris@0
|
336 if ($defaultValue == $defaultName) {
|
Chris@0
|
337 return $defaultTypedValue;
|
Chris@0
|
338 }
|
Chris@0
|
339 }
|
Chris@0
|
340 return $defaultValue;
|
Chris@0
|
341 }
|
Chris@0
|
342
|
Chris@0
|
343 /**
|
Chris@0
|
344 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
|
Chris@0
|
345 * convert the data into the last of these forms.
|
Chris@0
|
346 */
|
Chris@0
|
347 protected static function convertListToCommaSeparated($text)
|
Chris@0
|
348 {
|
Chris@0
|
349 return preg_replace('#[ \t\n\r,]+#', ',', $text);
|
Chris@0
|
350 }
|
Chris@0
|
351
|
Chris@0
|
352 /**
|
Chris@0
|
353 * Take a multiline description and convert it into a single
|
Chris@0
|
354 * long unbroken line.
|
Chris@0
|
355 */
|
Chris@0
|
356 protected static function removeLineBreaks($text)
|
Chris@0
|
357 {
|
Chris@0
|
358 return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));
|
Chris@0
|
359 }
|
Chris@0
|
360 }
|