Completed
Pull Request — master (#45)
by Greg
02:33
created

CommandInfo::getRawAnnotations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
1
<?php
2
namespace Consolidation\AnnotatedCommand\Parser;
3
4
use Symfony\Component\Console\Input\InputOption;
5
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
6
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
7
use Consolidation\AnnotatedCommand\AnnotationData;
8
9
/**
10
 * Given a class and method name, parse the annotations in the
11
 * DocBlock comment, and provide accessor methods for all of
12
 * the elements that are needed to create a Symfony Console Command.
13
 *
14
 * Note that the name of this class is now somewhat of a misnomer,
15
 * as we now use it to hold annotation data for hooks as well as commands.
16
 * It would probably be better to rename this to MethodInfo at some point.
17
 */
18
class CommandInfo
19
{
20
    /**
21
     * @var \ReflectionMethod
22
     */
23
    protected $reflection;
24
25
    /**
26
     * @var boolean
27
     * @var string
28
    */
29
    protected $docBlockIsParsed;
30
31
    /**
32
     * @var string
33
     */
34
    protected $name;
35
36
    /**
37
     * @var string
38
     */
39
    protected $description = '';
40
41
    /**
42
     * @var string
43
     */
44
    protected $help = '';
45
46
    /**
47
     * @var DefaultsWithDescriptions
48
     */
49
    protected $options;
50
51
    /**
52
     * @var DefaultsWithDescriptions
53
     */
54
    protected $arguments;
55
56
    /**
57
     * @var array
58
     */
59
    protected $exampleUsage = [];
60
61
    /**
62
     * @var AnnotationData
63
     */
64
    protected $otherAnnotations;
65
66
    /**
67
     * @var array
68
     */
69
    protected $aliases = [];
70
71
    /**
72
     * @var string
73
     */
74
    protected $methodName;
75
76
    /**
77
     * @var string
78
     */
79
    protected $returnType;
80
81
    /**
82
     * @var string
83
     */
84
    protected $optionParamName;
85
86
    /**
87
     * Create a new CommandInfo class for a particular method of a class.
88
     *
89
     * @param string|mixed $classNameOrInstance The name of a class, or an
90
     *   instance of it.
91
     * @param string $methodName The name of the method to get info about.
92
     */
93
    public function __construct($classNameOrInstance, $methodName)
94
    {
95
        $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
96
        $this->methodName = $methodName;
97
        $this->otherAnnotations = new AnnotationData();
98
        // Set up a default name for the command from the method name.
99
        // This can be overridden via @command or @name annotations.
100
        $this->name = $this->convertName($this->reflection->name);
101
        $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
102
        $this->arguments = $this->determineAgumentClassifications();
103
        // Remember the name of the last parameter, if it holds the options.
104
        // We will use this information to ignore @param annotations for the options.
105
        if (!empty($this->options)) {
106
            $this->optionParamName = $this->lastParameterName();
107
        }
108
    }
109
110
    /**
111
     * Recover the method name provided to the constructor.
112
     *
113
     * @return string
114
     */
115
    public function getMethodName()
116
    {
117
        return $this->methodName;
118
    }
119
120
    /**
121
     * Return the primary name for this command.
122
     *
123
     * @return string
124
     */
125
    public function getName()
126
    {
127
        $this->parseDocBlock();
128
        return $this->name;
129
    }
130
131
    /**
132
     * Set the primary name for this command.
133
     *
134
     * @param string $name
135
     */
136
    public function setName($name)
137
    {
138
        $this->name = $name;
139
    }
140
141
    public function getReturnType()
142
    {
143
        $this->parseDocBlock();
144
        return $this->returnType;
145
    }
146
147
    public function setReturnType($returnType)
148
    {
149
        $this->returnType = $returnType;
150
    }
151
152
    /**
153
     * Get any annotations included in the docblock comment for the
154
     * implementation method of this command that are not already
155
     * handled by the primary methods of this class.
156
     *
157
     * @return AnnotationData
158
     */
159
    public function getRawAnnotations()
160
    {
161
        $this->parseDocBlock();
162
        return $this->otherAnnotations;
163
    }
164
165
    /**
166
     * Get any annotations included in the docblock comment,
167
     * also including default values such as @command.  We add
168
     * in the default @command annotation late, and only in a
169
     * copy of the annotation data because we use the existance
170
     * of a @command to indicate that this CommandInfo is
171
     * a command, and not a hook or anything else.
172
     *
173
     * @return AnnotationData
174
     */
175
    public function getAnnotations()
176
    {
177
        return new AnnotationData(
178
            $this->getRawAnnotations()->getArrayCopy() +
179
            [
180
                'command' => $this->getName(),
181
            ]
182
        );
183
    }
184
185
    /**
186
     * Return a specific named annotation for this command.
187
     *
188
     * @param string $annotation The name of the annotation.
189
     * @return string
190
     */
191
    public function getAnnotation($annotation)
192
    {
193
        // hasAnnotation parses the docblock
194
        if (!$this->hasAnnotation($annotation)) {
195
            return null;
196
        }
197
        return $this->otherAnnotations[$annotation];
198
    }
199
200
    /**
201
     * Check to see if the specified annotation exists for this command.
202
     *
203
     * @param string $annotation The name of the annotation.
204
     * @return boolean
205
     */
206
    public function hasAnnotation($annotation)
207
    {
208
        $this->parseDocBlock();
209
        return isset($this->otherAnnotations[$annotation]);
210
    }
211
212
    /**
213
     * Save any tag that we do not explicitly recognize in the
214
     * 'otherAnnotations' map.
215
     */
216
    public function addOtherAnnotation($name, $content)
217
    {
218
        $this->otherAnnotations[$name] = $content;
219
    }
220
221
    /**
222
     * Get the synopsis of the command (~first line).
223
     *
224
     * @return string
225
     */
226
    public function getDescription()
227
    {
228
        $this->parseDocBlock();
229
        return $this->description;
230
    }
231
232
    /**
233
     * Set the command description.
234
     *
235
     * @param string $description The description to set.
236
     */
237
    public function setDescription($description)
238
    {
239
        $this->description = $description;
240
    }
241
242
    /**
243
     * Get the help text of the command (the description)
244
     */
245
    public function getHelp()
246
    {
247
        $this->parseDocBlock();
248
        return $this->help;
249
    }
250
    /**
251
     * Set the help text for this command.
252
     *
253
     * @param string $help The help text.
254
     */
255
    public function setHelp($help)
256
    {
257
        $this->help = $help;
258
    }
259
260
    /**
261
     * Return the list of aliases for this command.
262
     * @return string[]
263
     */
264
    public function getAliases()
265
    {
266
        $this->parseDocBlock();
267
        return $this->aliases;
268
    }
269
270
    /**
271
     * Set aliases that can be used in place of the command's primary name.
272
     *
273
     * @param string|string[] $aliases
274
     */
275
    public function setAliases($aliases)
276
    {
277
        if (is_string($aliases)) {
278
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
279
        }
280
        $this->aliases = array_filter($aliases);
281
    }
282
283
    /**
284
     * Return the examples for this command. This is @usage instead of
285
     * @example because the later is defined by the phpdoc standard to
286
     * be example method calls.
287
     *
288
     * @return string[]
289
     */
290
    public function getExampleUsages()
291
    {
292
        $this->parseDocBlock();
293
        return $this->exampleUsage;
294
    }
295
296
    /**
297
     * Add an example usage for this command.
298
     *
299
     * @param string $usage An example of the command, including the command
300
     *   name and all of its example arguments and options.
301
     * @param string $description An explanation of what the example does.
302
     */
303
    public function setExampleUsage($usage, $description)
304
    {
305
        $this->exampleUsage[$usage] = $description;
306
    }
307
308
    /**
309
     * Return the list of refleaction parameters.
310
     *
311
     * @return ReflectionParameter[]
312
     */
313
    public function getParameters()
314
    {
315
        return $this->reflection->getParameters();
316
    }
317
318
    /**
319
     * Descriptions of commandline arguements for this command.
320
     *
321
     * @return DefaultsWithDescriptions
322
     */
323
    public function arguments()
324
    {
325
        return $this->arguments;
326
    }
327
328
    /**
329
     * Descriptions of commandline options for this command.
330
     *
331
     * @return DefaultsWithDescriptions
332
     */
333
    public function options()
334
    {
335
        return $this->options;
336
    }
337
338
    /**
339
     * Return the name of the last parameter if it holds the options.
340
     */
341
    public function optionParamName()
342
    {
343
        return $this->optionParamName;
344
    }
345
346
    /**
347
     * Get the inputOptions for the options associated with this CommandInfo
348
     * object, e.g. via @option annotations, or from
349
     * $options = ['someoption' => 'defaultvalue'] in the command method
350
     * parameter list.
351
     *
352
     * @return InputOption[]
353
     */
354
    public function inputOptions()
355
    {
356
        $explicitOptions = [];
357
358
        $opts = $this->options()->getValues();
359
        foreach ($opts as $name => $defaultValue) {
360
            $description = $this->options()->getDescription($name);
361
362
            $fullName = $name;
363
            $shortcut = '';
364
            if (strpos($name, '|')) {
365
                list($fullName, $shortcut) = explode('|', $name, 2);
366
            }
367
368
            if (is_bool($defaultValue)) {
369
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
370
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
371
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
372
            } else {
373
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
374
            }
375
        }
376
377
        return $explicitOptions;
378
    }
379
380
    /**
381
     * An option might have a name such as 'silent|s'. In this
382
     * instance, we will allow the @option or @default tag to
383
     * reference the option only by name (e.g. 'silent' or 's'
384
     * instead of 'silent|s').
385
     *
386
     * @param string $optionName
387
     * @return string
388
     */
389
    public function findMatchingOption($optionName)
390
    {
391
        // Exit fast if there's an exact match
392
        if ($this->options->exists($optionName)) {
393
            return $optionName;
394
        }
395
        $existingOptionName = $this->findExistingOption($optionName);
396
        if (isset($existingOptionName)) {
397
            return $existingOptionName;
398
        }
399
        return $this->findOptionAmongAlternatives($optionName);
400
    }
401
402
    /**
403
     * @param string $optionName
404
     * @return string
405
     */
406
    protected function findOptionAmongAlternatives($optionName)
407
    {
408
        // Check the other direction: if the annotation contains @silent|s
409
        // and the options array has 'silent|s'.
410
        $checkMatching = explode('|', $optionName);
411
        if (count($checkMatching) > 1) {
412
            foreach ($checkMatching as $checkName) {
413
                if ($this->options->exists($checkName)) {
414
                    $this->options->rename($checkName, $optionName);
415
                    return $optionName;
416
                }
417
            }
418
        }
419
        return $optionName;
420
    }
421
422
    /**
423
     * @param string $optionName
424
     * @return string|null
425
     */
426
    protected function findExistingOption($optionName)
427
    {
428
        // Check to see if we can find the option name in an existing option,
429
        // e.g. if the options array has 'silent|s' => false, and the annotation
430
        // is @silent.
431
        foreach ($this->options()->getValues() as $name => $default) {
432
            if (in_array($optionName, explode('|', $name))) {
433
                return $name;
434
            }
435
        }
436
    }
437
438
    /**
439
     * Examine the parameters of the method for this command, and
440
     * build a list of commandline arguements for them.
441
     *
442
     * @return array
443
     */
444
    protected function determineAgumentClassifications()
445
    {
446
        $result = new DefaultsWithDescriptions();
447
        $params = $this->reflection->getParameters();
448
        $optionsFromParameters = $this->determineOptionsFromParameters();
449
        if (!empty($optionsFromParameters)) {
450
            array_pop($params);
451
        }
452
        foreach ($params as $param) {
453
            $this->addParameterToResult($result, $param);
454
        }
455
        return $result;
456
    }
457
458
    /**
459
     * Examine the provided parameter, and determine whether it
460
     * is a parameter that will be filled in with a positional
461
     * commandline argument.
462
     */
463
    protected function addParameterToResult($result, $param)
464
    {
465
        // Commandline arguments must be strings, so ignore any
466
        // parameter that is typehinted to any non-primative class.
467
        if ($param->getClass() != null) {
468
            return;
469
        }
470
        $result->add($param->name);
471
        if ($param->isDefaultValueAvailable()) {
472
            $defaultValue = $param->getDefaultValue();
473
            if (!$this->isAssoc($defaultValue)) {
474
                $result->setDefaultValue($param->name, $defaultValue);
475
            }
476
        } elseif ($param->isArray()) {
477
            $result->setDefaultValue($param->name, []);
478
        }
479
    }
480
481
    /**
482
     * Examine the parameters of the method for this command, and determine
483
     * the disposition of the options from them.
484
     *
485
     * @return array
486
     */
487
    protected function determineOptionsFromParameters()
488
    {
489
        $params = $this->reflection->getParameters();
490
        if (empty($params)) {
491
            return [];
492
        }
493
        $param = end($params);
494
        if (!$param->isDefaultValueAvailable()) {
495
            return [];
496
        }
497
        if (!$this->isAssoc($param->getDefaultValue())) {
498
            return [];
499
        }
500
        return $param->getDefaultValue();
501
    }
502
503
    protected function lastParameterName()
504
    {
505
        $params = $this->reflection->getParameters();
506
        $param = end($params);
507
        if (!$param) {
508
            return '';
509
        }
510
        return $param->name;
511
    }
512
513
    /**
514
     * Helper; determine if an array is associative or not. An array
515
     * is not associative if its keys are numeric, and numbered sequentially
516
     * from zero. All other arrays are considered to be associative.
517
     *
518
     * @param arrau $arr The array
519
     * @return boolean
520
     */
521
    protected function isAssoc($arr)
522
    {
523
        if (!is_array($arr)) {
524
            return false;
525
        }
526
        return array_keys($arr) !== range(0, count($arr) - 1);
527
    }
528
529
    /**
530
     * Convert from a method name to the corresponding command name. A
531
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
532
     * become 'foo:bar-baz-boz'.
533
     *
534
     * @param string $camel method name.
535
     * @return string
536
     */
537
    protected function convertName($camel)
538
    {
539
        $splitter="-";
540
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
541
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
542
        return strtolower($camel);
543
    }
544
545
    /**
546
     * Parse the docBlock comment for this command, and set the
547
     * fields of this class with the data thereby obtained.
548
     */
549
    protected function parseDocBlock()
550
    {
551
        if (!$this->docBlockIsParsed) {
552
            // The parse function will insert data from the provided method
553
            // into this object, using our accessors.
554
            CommandDocBlockParserFactory::parse($this, $this->reflection);
555
            $this->docBlockIsParsed = true;
556
        }
557
    }
558
559
    /**
560
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
561
     * convert the data into the last of these forms.
562
     */
563
    protected static function convertListToCommaSeparated($text)
564
    {
565
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
566
    }
567
}
568