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@0
|
23 'param' => 'processArgumentTag',
|
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@0
|
87 * Store the data from a @arg annotation in our argument descriptions.
|
Chris@0
|
88 */
|
Chris@0
|
89 protected function processArgumentTag($tag)
|
Chris@0
|
90 {
|
Chris@0
|
91 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
92 throw new \Exception('Could not determine argument name from tag ' . (string)$tag);
|
Chris@0
|
93 }
|
Chris@0
|
94 if ($matches['variable'] == $this->optionParamName()) {
|
Chris@0
|
95 return;
|
Chris@0
|
96 }
|
Chris@0
|
97 $this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $matches['variable'], $matches['description']);
|
Chris@0
|
98 }
|
Chris@0
|
99
|
Chris@0
|
100 /**
|
Chris@0
|
101 * Store the data from an @option annotation in our option descriptions.
|
Chris@0
|
102 */
|
Chris@0
|
103 protected function processOptionTag($tag)
|
Chris@0
|
104 {
|
Chris@0
|
105 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
106 throw new \Exception('Could not determine option name from tag ' . (string)$tag);
|
Chris@0
|
107 }
|
Chris@0
|
108 $this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $matches['variable'], $matches['description']);
|
Chris@0
|
109 }
|
Chris@0
|
110
|
Chris@0
|
111 protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $name, $description)
|
Chris@0
|
112 {
|
Chris@0
|
113 $variableName = $this->commandInfo->findMatchingOption($name);
|
Chris@0
|
114 $description = static::removeLineBreaks($description);
|
Chris@0
|
115 $set->add($variableName, $description);
|
Chris@0
|
116 }
|
Chris@0
|
117
|
Chris@0
|
118 /**
|
Chris@0
|
119 * Store the data from a @default annotation in our argument or option store,
|
Chris@0
|
120 * as appropriate.
|
Chris@0
|
121 */
|
Chris@0
|
122 protected function processDefaultTag($tag)
|
Chris@0
|
123 {
|
Chris@0
|
124 if (!$tag->hasVariable($matches)) {
|
Chris@0
|
125 throw new \Exception('Could not determine parameter name for default value from tag ' . (string)$tag);
|
Chris@0
|
126 }
|
Chris@0
|
127 $variableName = $matches['variable'];
|
Chris@0
|
128 $defaultValue = $this->interpretDefaultValue($matches['description']);
|
Chris@0
|
129 if ($this->commandInfo->arguments()->exists($variableName)) {
|
Chris@0
|
130 $this->commandInfo->arguments()->setDefaultValue($variableName, $defaultValue);
|
Chris@0
|
131 return;
|
Chris@0
|
132 }
|
Chris@0
|
133 $variableName = $this->commandInfo->findMatchingOption($variableName);
|
Chris@0
|
134 if ($this->commandInfo->options()->exists($variableName)) {
|
Chris@0
|
135 $this->commandInfo->options()->setDefaultValue($variableName, $defaultValue);
|
Chris@0
|
136 }
|
Chris@0
|
137 }
|
Chris@0
|
138
|
Chris@0
|
139 /**
|
Chris@0
|
140 * Store the data from a @usage annotation in our example usage list.
|
Chris@0
|
141 */
|
Chris@0
|
142 protected function processUsageTag($tag)
|
Chris@0
|
143 {
|
Chris@0
|
144 $lines = explode("\n", $tag->getContent());
|
Chris@0
|
145 $usage = trim(array_shift($lines));
|
Chris@0
|
146 $description = static::removeLineBreaks(implode("\n", array_map(function ($line) {
|
Chris@0
|
147 return trim($line);
|
Chris@0
|
148 }, $lines)));
|
Chris@0
|
149
|
Chris@0
|
150 $this->commandInfo->setExampleUsage($usage, $description);
|
Chris@0
|
151 }
|
Chris@0
|
152
|
Chris@0
|
153 /**
|
Chris@0
|
154 * Process the comma-separated list of aliases
|
Chris@0
|
155 */
|
Chris@0
|
156 protected function processAliases($tag)
|
Chris@0
|
157 {
|
Chris@0
|
158 $this->commandInfo->setAliases((string)$tag->getContent());
|
Chris@0
|
159 }
|
Chris@0
|
160
|
Chris@0
|
161 /**
|
Chris@0
|
162 * Store the data from a @return annotation in our argument descriptions.
|
Chris@0
|
163 */
|
Chris@0
|
164 protected function processReturnTag($tag)
|
Chris@0
|
165 {
|
Chris@0
|
166 // The return type might be a variable -- '$this'. It will
|
Chris@0
|
167 // usually be a type, like RowsOfFields, or \Namespace\RowsOfFields.
|
Chris@0
|
168 if (!$tag->hasVariableAndDescription($matches)) {
|
Chris@0
|
169 throw new \Exception('Could not determine return type from tag ' . (string)$tag);
|
Chris@0
|
170 }
|
Chris@0
|
171 // Look at namespace and `use` statments to make returnType a fqdn
|
Chris@0
|
172 $returnType = $matches['variable'];
|
Chris@0
|
173 $returnType = $this->findFullyQualifiedClass($returnType);
|
Chris@0
|
174 $this->commandInfo->setReturnType($returnType);
|
Chris@0
|
175 }
|
Chris@0
|
176
|
Chris@0
|
177 protected function findFullyQualifiedClass($className)
|
Chris@0
|
178 {
|
Chris@0
|
179 if (strpos($className, '\\') !== false) {
|
Chris@0
|
180 return $className;
|
Chris@0
|
181 }
|
Chris@0
|
182
|
Chris@0
|
183 return $this->fqcnCache->qualify($this->reflection->getFileName(), $className);
|
Chris@0
|
184 }
|
Chris@0
|
185
|
Chris@0
|
186 private function parseDocBlock($doc)
|
Chris@0
|
187 {
|
Chris@0
|
188 // Remove the leading /** and the trailing */
|
Chris@0
|
189 $doc = preg_replace('#^\s*/\*+\s*#', '', $doc);
|
Chris@0
|
190 $doc = preg_replace('#\s*\*+/\s*#', '', $doc);
|
Chris@0
|
191
|
Chris@0
|
192 // Nothing left? Exit.
|
Chris@0
|
193 if (empty($doc)) {
|
Chris@0
|
194 return;
|
Chris@0
|
195 }
|
Chris@0
|
196
|
Chris@0
|
197 $tagFactory = new TagFactory();
|
Chris@0
|
198 $lines = [];
|
Chris@0
|
199
|
Chris@0
|
200 foreach (explode("\n", $doc) as $row) {
|
Chris@0
|
201 // Remove trailing whitespace and leading space + '*'s
|
Chris@0
|
202 $row = rtrim($row);
|
Chris@0
|
203 $row = preg_replace('#^[ \t]*\**#', '', $row);
|
Chris@0
|
204
|
Chris@0
|
205 if (!$tagFactory->parseLine($row)) {
|
Chris@0
|
206 $lines[] = $row;
|
Chris@0
|
207 }
|
Chris@0
|
208 }
|
Chris@0
|
209
|
Chris@0
|
210 $this->processDescriptionAndHelp($lines);
|
Chris@0
|
211 $this->processAllTags($tagFactory->getTags());
|
Chris@0
|
212 }
|
Chris@0
|
213
|
Chris@0
|
214 protected function processDescriptionAndHelp($lines)
|
Chris@0
|
215 {
|
Chris@0
|
216 // Trim all of the lines individually.
|
Chris@0
|
217 $lines =
|
Chris@0
|
218 array_map(
|
Chris@0
|
219 function ($line) {
|
Chris@0
|
220 return trim($line);
|
Chris@0
|
221 },
|
Chris@0
|
222 $lines
|
Chris@0
|
223 );
|
Chris@0
|
224
|
Chris@0
|
225 // Everything up to the first blank line goes in the description.
|
Chris@0
|
226 $description = array_shift($lines);
|
Chris@0
|
227 while ($this->nextLineIsNotEmpty($lines)) {
|
Chris@0
|
228 $description .= ' ' . array_shift($lines);
|
Chris@0
|
229 }
|
Chris@0
|
230
|
Chris@0
|
231 // Everything else goes in the help.
|
Chris@0
|
232 $help = trim(implode("\n", $lines));
|
Chris@0
|
233
|
Chris@0
|
234 $this->commandInfo->setDescription($description);
|
Chris@0
|
235 $this->commandInfo->setHelp($help);
|
Chris@0
|
236 }
|
Chris@0
|
237
|
Chris@0
|
238 protected function nextLineIsNotEmpty($lines)
|
Chris@0
|
239 {
|
Chris@0
|
240 if (empty($lines)) {
|
Chris@0
|
241 return false;
|
Chris@0
|
242 }
|
Chris@0
|
243
|
Chris@0
|
244 $nextLine = trim($lines[0]);
|
Chris@0
|
245 return !empty($nextLine);
|
Chris@0
|
246 }
|
Chris@0
|
247
|
Chris@0
|
248 protected function processAllTags($tags)
|
Chris@0
|
249 {
|
Chris@0
|
250 // Iterate over all of the tags, and process them as necessary.
|
Chris@0
|
251 foreach ($tags as $tag) {
|
Chris@0
|
252 $processFn = [$this, 'processGenericTag'];
|
Chris@0
|
253 if (array_key_exists($tag->getTag(), $this->tagProcessors)) {
|
Chris@0
|
254 $processFn = [$this, $this->tagProcessors[$tag->getTag()]];
|
Chris@0
|
255 }
|
Chris@0
|
256 $processFn($tag);
|
Chris@0
|
257 }
|
Chris@0
|
258 }
|
Chris@0
|
259
|
Chris@0
|
260 protected function lastParameterName()
|
Chris@0
|
261 {
|
Chris@0
|
262 $params = $this->commandInfo->getParameters();
|
Chris@0
|
263 $param = end($params);
|
Chris@0
|
264 if (!$param) {
|
Chris@0
|
265 return '';
|
Chris@0
|
266 }
|
Chris@0
|
267 return $param->name;
|
Chris@0
|
268 }
|
Chris@0
|
269
|
Chris@0
|
270 /**
|
Chris@0
|
271 * Return the name of the last parameter if it holds the options.
|
Chris@0
|
272 */
|
Chris@0
|
273 public function optionParamName()
|
Chris@0
|
274 {
|
Chris@0
|
275 // Remember the name of the last parameter, if it holds the options.
|
Chris@0
|
276 // We will use this information to ignore @param annotations for the options.
|
Chris@0
|
277 if (!isset($this->optionParamName)) {
|
Chris@0
|
278 $this->optionParamName = '';
|
Chris@0
|
279 $options = $this->commandInfo->options();
|
Chris@0
|
280 if (!$options->isEmpty()) {
|
Chris@0
|
281 $this->optionParamName = $this->lastParameterName();
|
Chris@0
|
282 }
|
Chris@0
|
283 }
|
Chris@0
|
284
|
Chris@0
|
285 return $this->optionParamName;
|
Chris@0
|
286 }
|
Chris@0
|
287
|
Chris@0
|
288 protected function interpretDefaultValue($defaultValue)
|
Chris@0
|
289 {
|
Chris@0
|
290 $defaults = [
|
Chris@0
|
291 'null' => null,
|
Chris@0
|
292 'true' => true,
|
Chris@0
|
293 'false' => false,
|
Chris@0
|
294 "''" => '',
|
Chris@0
|
295 '[]' => [],
|
Chris@0
|
296 ];
|
Chris@0
|
297 foreach ($defaults as $defaultName => $defaultTypedValue) {
|
Chris@0
|
298 if ($defaultValue == $defaultName) {
|
Chris@0
|
299 return $defaultTypedValue;
|
Chris@0
|
300 }
|
Chris@0
|
301 }
|
Chris@0
|
302 return $defaultValue;
|
Chris@0
|
303 }
|
Chris@0
|
304
|
Chris@0
|
305 /**
|
Chris@0
|
306 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
|
Chris@0
|
307 * convert the data into the last of these forms.
|
Chris@0
|
308 */
|
Chris@0
|
309 protected static function convertListToCommaSeparated($text)
|
Chris@0
|
310 {
|
Chris@0
|
311 return preg_replace('#[ \t\n\r,]+#', ',', $text);
|
Chris@0
|
312 }
|
Chris@0
|
313
|
Chris@0
|
314 /**
|
Chris@0
|
315 * Take a multiline description and convert it into a single
|
Chris@0
|
316 * long unbroken line.
|
Chris@0
|
317 */
|
Chris@0
|
318 protected static function removeLineBreaks($text)
|
Chris@0
|
319 {
|
Chris@0
|
320 return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));
|
Chris@0
|
321 }
|
Chris@0
|
322 }
|