Completed
Push — master ( f0da9f...338f66 )
by Greg
03:53 queued 01:36
created

CommandInfo::hasAnnotation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 5
rs 9.4285
cc 1
eloc 3
nc 1
nop 1
1
<?php
2
namespace Consolidation\AnnotatedCommand;
3
4
use phpDocumentor\Reflection\DocBlock\Tag\ParamTag;
5
use phpDocumentor\Reflection\DocBlock;
6
7
/**
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 a Symfony Console Command.
11
 */
12
class CommandInfo
13
{
14
    /**
15
     * @var \ReflectionMethod
16
     */
17
    protected $reflection;
18
19
    /**
20
     * @var boolean
21
     * @var string
22
    */
23
    protected $docBlockIsParsed;
24
25
    /**
26
     * @var string
27
     */
28
    protected $name;
29
30
    /**
31
     * @var string
32
     */
33
    protected $description = '';
34
35
    /**
36
     * @var string
37
     */
38
    protected $help = '';
39
40
    /**
41
     * @var array
42
     */
43
    protected $tagProcessors = [
44
        'command' => 'processCommandTag',
45
        'name' => 'processCommandTag',
46
        'param' => 'processArgumentTag',
47
        'option' => 'processOptionTag',
48
        'default' => 'processDefaultTag',
49
        'aliases' => 'processAliases',
50
        'usage' => 'processUsageTag',
51
        'description' => 'processAlternateDescriptionTag',
52
        'desc' => 'processAlternateDescriptionTag',
53
    ];
54
55
    /**
56
     * @var array
57
     */
58
    protected $options = [];
59
60
    /**
61
     * @var array
62
     */
63
    protected $arguments = [];
64
65
    /**
66
     * @var array
67
     */
68
    protected $argumentDescriptions = [];
69
70
    /**
71
     * @var array
72
     */
73
    protected $optionDescriptions = [];
74
75
    /**
76
     * @var array
77
     */
78
    protected $exampleUsage = [];
79
80
    /**
81
     * @var array
82
     */
83
    protected $otherAnnotations = [];
84
85
    /**
86
     * @var array
87
     */
88
    protected $aliases = [];
89
90
    /**
91
     * @var string
92
     */
93
    protected $methodName;
94
95
    public function __construct($classNameOrInstance, $methodName)
96
    {
97
        $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
98
        $this->methodName = $methodName;
99
        // Set up a default name for the command from the method name.
100
        // This can be overridden via @command or @name annotations.
101
        $this->setDefaultName();
102
        $this->options = $this->determineOptionsFromParameters();
103
        $this->arguments = $this->determineAgumentClassifications();
104
    }
105
106
    public function getMethodName()
107
    {
108
        return $this->methodName;
109
    }
110
111
    public function getParameters()
112
    {
113
        return $this->reflection->getParameters();
114
    }
115
116
    /**
117
     * Get the synopsis of the command (~first line).
118
     */
119
    public function getDescription()
120
    {
121
        $this->parseDocBlock();
122
        return $this->description;
123
    }
124
125
    public function setDescription($description)
126
    {
127
        $this->description = $description;
128
    }
129
130
    /**
131
     * Get the help text of the command (the description)
132
     */
133
    public function getHelp()
134
    {
135
        $this->parseDocBlock();
136
        return $this->help;
137
    }
138
139
    public function setHelp($help)
140
    {
141
        $this->help = $help;
142
    }
143
144
    public function getAliases()
145
    {
146
        $this->parseDocBlock();
147
        return $this->aliases;
148
    }
149
150
    public function setAliases($aliases)
151
    {
152
        if (is_string($aliases)) {
153
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
154
        }
155
        $this->aliases = array_filter($aliases);
156
    }
157
158
    public function getExampleUsages()
159
    {
160
        $this->parseDocBlock();
161
        return $this->exampleUsage;
162
    }
163
164
    public function getName()
165
    {
166
        $this->parseDocBlock();
167
        return $this->name;
168
    }
169
170
    public function setDefaultName()
171
    {
172
        $this->name = $this->convertName($this->reflection->name);
173
    }
174
175
    protected function determineAgumentClassifications()
176
    {
177
        $args = [];
178
        $params = $this->reflection->getParameters();
179
        if (!empty($this->determineOptionsFromParameters())) {
180
            array_pop($params);
181
        }
182
        foreach ($params as $param) {
183
            $defaultValue = $this->getArgumentClassification($param);
184
            if ($defaultValue !== false) {
185
                $args[$param->name] = $defaultValue;
186
            }
187
        }
188
        return $args;
189
    }
190
191
    public function getArguments()
192
    {
193
        return $this->arguments;
194
    }
195
196
    /**
197
     * Examine the provided parameter, and determine whether it
198
     * is a parameter that will be filled in with a positional
199
     * commandline argument.
200
     *
201
     * @return false|null|string|array
202
     */
203
    protected function getArgumentClassification($param)
204
    {
205
        $defaultValue = null;
206
        if ($param->isDefaultValueAvailable()) {
207
            $defaultValue = $param->getDefaultValue();
208
            if ($this->isAssoc($defaultValue)) {
209
                return false;
210
            }
211
        }
212
        if ($param->isArray()) {
213
            return [];
214
        }
215
        // Commandline arguments must be strings, so ignore
216
        // any parameter that is typehinted to anything else.
217
        if (($param->getClass() != null) && ($param->getClass() != 'string')) {
218
            return false;
219
        }
220
        return $defaultValue;
221
    }
222
223
    public function getOptions()
224
    {
225
        return $this->options;
226
    }
227
228
    public function determineOptionsFromParameters()
229
    {
230
        $params = $this->reflection->getParameters();
231
        if (empty($params)) {
232
            return [];
233
        }
234
        $param = end($params);
235
        if (!$param->isDefaultValueAvailable()) {
236
            return [];
237
        }
238
        if (!$this->isAssoc($param->getDefaultValue())) {
239
            return [];
240
        }
241
        return $param->getDefaultValue();
242
    }
243
244
    public function getArgumentDescription($name)
245
    {
246
        $this->parseDocBlock();
247
        if (array_key_exists($name, $this->argumentDescriptions)) {
248
            return $this->argumentDescriptions[$name];
249
        }
250
251
        return '';
252
    }
253
254
    public function getOptionDescription($name)
255
    {
256
        $this->parseDocBlock();
257
        if (array_key_exists($name, $this->optionDescriptions)) {
258
            return $this->optionDescriptions[$name];
259
        }
260
261
        return '';
262
    }
263
264
    protected function isAssoc($arr)
265
    {
266
        if (!is_array($arr)) {
267
            return false;
268
        }
269
        return array_keys($arr) !== range(0, count($arr) - 1);
270
    }
271
272
    public function getAnnotations()
273
    {
274
        $this->parseDocBlock();
275
        return $this->otherAnnotations;
276
    }
277
278
    public function getAnnotation($annotation)
279
    {
280
        // hasAnnotation parses the docblock
281
        if (!$this->hasAnnotation($annotation)) {
282
            return null;
283
        }
284
        return $this->otherAnnotations[$annotation];
285
    }
286
287
    public function hasAnnotation($annotation)
288
    {
289
        $this->parseDocBlock();
290
        return array_key_exists($annotation, $this->otherAnnotations);
291
    }
292
293
    protected function convertName($camel)
294
    {
295
        $splitter="-";
296
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
297
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
298
        return strtolower($camel);
299
    }
300
301
    /**
302
     * Parse the docBlock comment for this command, and set the
303
     * fields of this class with the data thereby obtained.
304
     */
305
    protected function parseDocBlock()
306
    {
307
        if (!$this->docBlockIsParsed) {
308
            $docblock = $this->reflection->getDocComment();
309
            $phpdoc = new DocBlock($docblock);
310
311
            // First set the description (synopsis) and help.
312
            $this->setDescription((string)$phpdoc->getShortDescription());
313
            $this->setHelp((string)$phpdoc->getLongDescription());
314
315
            // Iterate over all of the tags, and process them as necessary.
316
            foreach ($phpdoc->getTags() as $tag) {
317
                $processFn = [$this, 'processGenericTag'];
318
                if (array_key_exists($tag->getName(), $this->tagProcessors)) {
319
                    $processFn = [$this, $this->tagProcessors[$tag->getName()]];
320
                }
321
                $processFn($tag);
322
            }
323
            $this->docBlockIsParsed = true;
324
        }
325
    }
326
327
    /**
328
     * Save any tag that we do not explicitly recognize in the
329
     * 'otherAnnotations' map.
330
     */
331
    protected function processGenericTag($tag)
332
    {
333
        $this->otherAnnotations[$tag->getName()] = $tag->getContent();
334
    }
335
336
    /**
337
     * Set the name of the command from a @command or @name annotation.
338
     */
339
    protected function processCommandTag($tag)
340
    {
341
        $this->name = $tag->getContent();
342
        // We also store the name in the 'other annotations' so that is is
343
        // possible to determine if the method had a @command annotation.
344
        $this->processGenericTag($tag);
345
    }
346
347
    /**
348
     * The @description and @desc annotations may be used in
349
     * place of the synopsis (which we call 'description').
350
     * This is discouraged.
351
     *
352
     * @deprecated
353
     */
354
    protected function processAlternateDescriptionTag($tag)
355
    {
356
        $this->setDescription($tag->getContent());
357
    }
358
359
    /**
360
     * Store the data from a @param annotation in our argument descriptions.
361
     */
362
    protected function processArgumentTag($tag)
363
    {
364
        if ($tag instanceof ParamTag) {
365
            $variableName = $tag->getVariableName();
366
            $variableName = str_replace('$', '', $variableName);
367
            $this->argumentDescriptions[$variableName] = static::removeLineBreaks($tag->getDescription());
368
            if (!isset($this->arguments[$variableName])) {
369
                $this->arguments[$variableName] = null;
370
            }
371
        }
372
    }
373
374
    /**
375
     * Given a docblock description in the form "$variable description",
376
     * return the variable name and description via the 'match' parameter.
377
     */
378
    protected function pregMatchNameAndDescription($source, &$match)
379
    {
380
        $nameRegEx = '\\$(?P<name>[^ \t]+)[ \t]+';
381
        $descriptionRegEx = '(?P<description>.*)';
382
        $optionRegEx = "/{$nameRegEx}{$descriptionRegEx}/s";
383
384
        return preg_match($optionRegEx, $source, $match);
385
    }
386
387
    /**
388
     * Store the data from an @option annotation in our option descriptions.
389
     */
390
    protected function processOptionTag($tag)
391
    {
392
        if ($this->pregMatchNameAndDescription($tag->getDescription(), $match)) {
393
            $variableName = $match['name'];
394
            $desc = $match['description'];
395
            $this->optionDescriptions[$variableName] = static::removeLineBreaks($desc);
396
            if (!isset($this->options[$variableName])) {
397
                $this->options[$variableName] = false;
398
            }
399
        }
400
    }
401
402
    /**
403
     * Store the data from a @default annotation in our argument or option store,
404
     * as appropriate.
405
     */
406
    protected function processDefaultTag($tag)
407
    {
408
        if ($this->pregMatchNameAndDescription($tag->getDescription(), $match)) {
409
            $variableName = $match['name'];
410
            $defaultValue = $this->interpretDefaultValue($match['description']);
411
            if (array_key_exists($variableName, $this->arguments)) {
412
                $this->arguments[$variableName] = $defaultValue;
413
            }
414
            if (array_key_exists($variableName, $this->options)) {
415
                $this->options[$variableName] = $defaultValue;
416
            }
417
        }
418
    }
419
420
    protected function interpretDefaultValue($defaultValue)
421
    {
422
        $defaults = [
423
            'null' => null,
424
            'true' => true,
425
            'false' => false,
426
            "''" => '',
427
            '[]' => [],
428
        ];
429
        foreach ($defaults as $defaultName => $defaultTypedValue) {
430
            if ($defaultValue == $defaultName) {
431
                return $defaultTypedValue;
432
            }
433
        }
434
        return $defaultValue;
435
    }
436
437
    /**
438
     * Process the comma-separated list of aliases
439
     */
440
    protected function processAliases($tag)
441
    {
442
        $this->setAliases($tag->getDescription());
443
    }
444
445
    /**
446
     * Store the data from a @usage annotation in our example usage list.
447
     */
448
    protected function processUsageTag($tag)
449
    {
450
        $lines = explode("\n", $tag->getContent());
451
        $usage = array_shift($lines);
452
        $description = static::removeLineBreaks(implode("\n", $lines));
453
454
        $this->exampleUsage[$usage] = $description;
455
    }
456
457
    /**
458
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
459
     * convert the data into the last of these forms.
460
     */
461
    protected static function convertListToCommaSeparated($text)
462
    {
463
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
464
    }
465
466
    /**
467
     * Take a multiline description and convert it into a single
468
     * long unbroken line.
469
     */
470
    protected static function removeLineBreaks($text)
471
    {
472
        return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));
473
    }
474
}
475