Completed
Pull Request — master (#66)
by Greg
04:48
created

CommandInfo::setName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 5
rs 9.4285
cc 1
eloc 3
nc 1
nop 1
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
     * Serialization schema version. Incremented every time the serialization schema changes.
22
     */
23
    const SERIALIZATION_SCHEMA_VERSION = 1;
24
25
    /**
26
     * @var \ReflectionMethod
27
     */
28
    protected $reflection;
29
30
    /**
31
     * @var boolean
32
     * @var string
33
    */
34
    protected $docBlockIsParsed = false;
35
36
    /**
37
     * @var string
38
     */
39
    protected $name;
40
41
    /**
42
     * @var string
43
     */
44
    protected $description = '';
45
46
    /**
47
     * @var string
48
     */
49
    protected $help = '';
50
51
    /**
52
     * @var DefaultsWithDescriptions
53
     */
54
    protected $options;
55
56
    /**
57
     * @var DefaultsWithDescriptions
58
     */
59
    protected $arguments;
60
61
    /**
62
     * @var array
63
     */
64
    protected $exampleUsage = [];
65
66
    /**
67
     * @var AnnotationData
68
     */
69
    protected $otherAnnotations;
70
71
    /**
72
     * @var array
73
     */
74
    protected $aliases = [];
75
76
    /**
77
     * @var InputOption[]
78
     */
79
    protected $inputOptions;
80
81
    /**
82
     * @var string
83
     */
84
    protected $methodName;
85
86
    /**
87
     * @var string
88
     */
89
    protected $returnType;
90
91
    /**
92
     * Create a new CommandInfo class for a particular method of a class.
93
     *
94
     * @param string|mixed $classNameOrInstance The name of a class, or an
95
     *   instance of it, or an array of cached data.
96
     * @param string $methodName The name of the method to get info about.
97
     * @param array $cache Cached data
98
     * @deprecated Use CommandInfo::create() or CommandInfo::deserialize()
99
     *   instead. In the future, this constructor will be protected.
100
     */
101
    public function __construct($classNameOrInstance, $methodName, $cache = [])
102
    {
103
        $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
104
        $this->methodName = $methodName;
105
106
        if (!empty($cache)) {
107
            $this->constructFromCache($cache);
108
            $this->docBlockIsParsed = true;
109
        } else {
110
            $this->constructFromClassAndMethod($classNameOrInstance, $methodName);
111
        }
112
    }
113
114
    public static function create($classNameOrInstance, $methodName)
115
    {
116
        return new self($classNameOrInstance, $methodName);
117
    }
118
119
    public static function deserialize($cache)
120
    {
121
        $classNameOrInstance = $cache['class'];
122
        $methodName = $cache['method_name'];
123
124
        // If the cache came from a newer version, ignore it and
125
        // regenerate the cached information.
126
        if (!isset($cache['schema']) || ($cache['schema'] > self::SERIALIZATION_SCHEMA_VERSION)) {
127
            return self::create($classNameOrInstance, $methodName);
128
        }
129
        return new self($classNameOrInstance, $methodName, $cache);
130
    }
131
132
    protected function constructFromClassAndMethod($classNameOrInstance, $methodName)
0 ignored issues
show
Unused Code introduced by
The parameter $classNameOrInstance is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
133
    {
134
        $this->otherAnnotations = new AnnotationData();
135
        // Set up a default name for the command from the method name.
136
        // This can be overridden via @command or @name annotations.
137
        $this->name = $this->convertName($methodName);
138
        $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
139
        $this->arguments = $this->determineAgumentClassifications();
140
    }
141
142
    protected function constructFromCache($info_array)
143
    {
144
        $info_array += $this->defaultSerializationData();
145
146
        $this->name = $info_array['name'];
147
        $this->methodName = $info_array['method_name'];
148
        $this->otherAnnotations = new AnnotationData((array) $info_array['annotations']);
149
        $this->arguments = new DefaultsWithDescriptions();
150
        $this->options = new DefaultsWithDescriptions();
151
        $this->aliases = $info_array['aliases'];
152
        $this->help = $info_array['help'];
153
        $this->description = $info_array['description'];
154
        $this->exampleUsage = $info_array['example_usages'];
155
        $this->returnType = $info_array['return_type'];
0 ignored issues
show
Documentation Bug introduced by
It seems like $info_array['return_type'] of type array is incompatible with the declared type string of property $returnType.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
156
157 View Code Duplication
        foreach ((array)$info_array['arguments'] as $key => $info) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
158
            $info = (array)$info;
159
            $this->arguments->add($key, $info['description']);
160
            if (array_key_exists('default', $info)) {
161
                $this->arguments->setDefaultValue($key, $info['default']);
162
            }
163
        }
164 View Code Duplication
        foreach ((array)$info_array['options'] as $key => $info) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
165
            $info = (array)$info;
166
            $this->options->add($key, $info['description']);
167
            if (array_key_exists('default', $info)) {
168
                $this->options->setDefaultValue($key, $info['default']);
169
            }
170
        }
171
172
        $this->input_options = [];
0 ignored issues
show
Bug introduced by
The property input_options does not seem to exist. Did you mean options?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
173
        foreach ((array)$info_array['input_options'] as $i => $option) {
174
            $option = (array) $option;
175
            $this->inputOptions[$i] = new InputOption(
176
                $option['name'],
177
                $option['shortcut'],
178
                $option['mode'],
179
                $option['description'],
180
                $option['default']
181
            );
182
        }
183
    }
184
185
    public function serialize()
186
    {
187
        $info = [
188
            'schema' => self::SERIALIZATION_SCHEMA_VERSION,
189
            'class' => $this->reflection->getDeclaringClass()->getName(),
190
            'method_name' => $this->getMethodName(),
191
            'name' => $this->getName(),
192
            'description' => $this->getDescription(),
193
            'help' => $this->getHelp(),
194
            'aliases' => $this->getAliases(),
195
            'annotations' => $this->getAnnotations()->getArrayCopy(),
196
            // Todo: Test This.
197
            'topics' => $this->getTopics(),
198
            'example_usages' => $this->getExampleUsages(),
199
            'return_type' => $this->getReturnType(),
200
        ] + $this->defaultSerializationData();
201 View Code Duplication
        foreach ($this->arguments()->getValues() as $key => $val) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
202
            $info['arguments'][$key] = [
203
                'description' => $this->arguments()->getDescription($key),
204
            ];
205
            if ($this->arguments()->hasDefault($key)) {
206
                $info['arguments'][$key]['default'] = $val;
207
            }
208
        }
209 View Code Duplication
        foreach ($this->options()->getValues() as $key => $val) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
210
            $info['options'][$key] = [
211
                'description' => $this->options()->getDescription($key),
212
            ];
213
            if ($this->options()->hasDefault($key)) {
214
                $info['options'][$key]['default'] = $val;
215
            }
216
        }
217
        foreach ($this->getParameters() as $i => $parameter) {
0 ignored issues
show
Unused Code introduced by
This foreach statement is empty and can be removed.

This check looks for foreach loops that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

Consider removing the loop.

Loading history...
218
            // TODO: Also cache input/output params
219
        }
220
        foreach ($this->inputOptions() as $i => $option) {
221
            $mode = 0;
222
            if ($option->isValueRequired()) {
223
                $mode |= InputOption::VALUE_REQUIRED;
224
            }
225
            if ($option->isValueOptional()) {
226
                $mode |= InputOption::VALUE_OPTIONAL;
227
            }
228
            if ($option->isArray()) {
229
                $mode |= InputOption::VALUE_IS_ARRAY;
230
            }
231
            if (!$mode) {
232
                $mode = InputOption::VALUE_NONE;
233
            }
234
235
            $info['input_options'][$i] = [
236
                'name' => $option->getName(),
237
                'shortcut' => $option->getShortcut(),
238
                'mode' => $mode,
239
                'description' => $option->getDescription(),
240
                'default' => null,
241
            ];
242
            if ($option->isValueOptional()) {
243
                $info['input_options'][$i]['default'] = $option->getDefault();
244
            }
245
        }
246
        return $info;
247
    }
248
249
    /**
250
     * Default data for serialization.
251
     * @return array
252
     */
253
    protected function defaultSerializationData()
254
    {
255
        return [
256
            'description' => '',
257
            'help' => '',
258
            'aliases' => [],
259
            'annotations' => [],
260
            'topics' => [],
261
            'example_usages' => [],
262
            'return_type' => [],
263
            'parameters' => [],
264
            'arguments' => [],
265
            'arguments' => [],
266
            'options' => [],
267
            'input_options' => [],
268
        ];
269
    }
270
271
    /**
272
     * Recover the method name provided to the constructor.
273
     *
274
     * @return string
275
     */
276
    public function getMethodName()
277
    {
278
        return $this->methodName;
279
    }
280
281
    /**
282
     * Return the primary name for this command.
283
     *
284
     * @return string
285
     */
286
    public function getName()
287
    {
288
        $this->parseDocBlock();
289
        return $this->name;
290
    }
291
292
    /**
293
     * Set the primary name for this command.
294
     *
295
     * @param string $name
296
     */
297
    public function setName($name)
298
    {
299
        $this->name = $name;
300
        return $this;
301
    }
302
303
    public function getReturnType()
304
    {
305
        $this->parseDocBlock();
306
        return $this->returnType;
307
    }
308
309
    public function setReturnType($returnType)
310
    {
311
        $this->returnType = $returnType;
312
        return $this;
313
    }
314
315
    /**
316
     * Get any annotations included in the docblock comment for the
317
     * implementation method of this command that are not already
318
     * handled by the primary methods of this class.
319
     *
320
     * @return AnnotationData
321
     */
322
    public function getRawAnnotations()
323
    {
324
        $this->parseDocBlock();
325
        return $this->otherAnnotations;
326
    }
327
328
    /**
329
     * Get any annotations included in the docblock comment,
330
     * also including default values such as @command.  We add
331
     * in the default @command annotation late, and only in a
332
     * copy of the annotation data because we use the existance
333
     * of a @command to indicate that this CommandInfo is
334
     * a command, and not a hook or anything else.
335
     *
336
     * @return AnnotationData
337
     */
338
    public function getAnnotations()
339
    {
340
        return new AnnotationData(
341
            $this->getRawAnnotations()->getArrayCopy() +
342
            [
343
                'command' => $this->getName(),
344
            ]
345
        );
346
    }
347
348
    /**
349
     * Return a specific named annotation for this command.
350
     *
351
     * @param string $annotation The name of the annotation.
352
     * @return string
353
     */
354
    public function getAnnotation($annotation)
355
    {
356
        // hasAnnotation parses the docblock
357
        if (!$this->hasAnnotation($annotation)) {
358
            return null;
359
        }
360
        return $this->otherAnnotations[$annotation];
361
    }
362
363
    /**
364
     * Check to see if the specified annotation exists for this command.
365
     *
366
     * @param string $annotation The name of the annotation.
367
     * @return boolean
368
     */
369
    public function hasAnnotation($annotation)
370
    {
371
        $this->parseDocBlock();
372
        return isset($this->otherAnnotations[$annotation]);
373
    }
374
375
    /**
376
     * Save any tag that we do not explicitly recognize in the
377
     * 'otherAnnotations' map.
378
     */
379
    public function addAnnotation($name, $content)
380
    {
381
        $this->otherAnnotations[$name] = $content;
382
    }
383
384
    /**
385
     * Remove an annotation that was previoudly set.
386
     */
387
    public function removeAnnotation($name)
388
    {
389
        unset($this->otherAnnotations[$name]);
390
    }
391
392
    /**
393
     * Get the synopsis of the command (~first line).
394
     *
395
     * @return string
396
     */
397
    public function getDescription()
398
    {
399
        $this->parseDocBlock();
400
        return $this->description;
401
    }
402
403
    /**
404
     * Set the command description.
405
     *
406
     * @param string $description The description to set.
407
     */
408
    public function setDescription($description)
409
    {
410
        $this->description = $description;
411
        return $this;
412
    }
413
414
    /**
415
     * Get the help text of the command (the description)
416
     */
417
    public function getHelp()
418
    {
419
        $this->parseDocBlock();
420
        return $this->help;
421
    }
422
    /**
423
     * Set the help text for this command.
424
     *
425
     * @param string $help The help text.
426
     */
427
    public function setHelp($help)
428
    {
429
        $this->help = $help;
430
        return $this;
431
    }
432
433
    /**
434
     * Return the list of aliases for this command.
435
     * @return string[]
436
     */
437
    public function getAliases()
438
    {
439
        $this->parseDocBlock();
440
        return $this->aliases;
441
    }
442
443
    /**
444
     * Set aliases that can be used in place of the command's primary name.
445
     *
446
     * @param string|string[] $aliases
447
     */
448
    public function setAliases($aliases)
449
    {
450
        if (is_string($aliases)) {
451
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
452
        }
453
        $this->aliases = array_filter($aliases);
454
        return $this;
455
    }
456
457
    /**
458
     * Return the examples for this command. This is @usage instead of
459
     * @example because the later is defined by the phpdoc standard to
460
     * be example method calls.
461
     *
462
     * @return string[]
463
     */
464
    public function getExampleUsages()
465
    {
466
        $this->parseDocBlock();
467
        return $this->exampleUsage;
468
    }
469
470
    /**
471
     * Add an example usage for this command.
472
     *
473
     * @param string $usage An example of the command, including the command
474
     *   name and all of its example arguments and options.
475
     * @param string $description An explanation of what the example does.
476
     */
477
    public function setExampleUsage($usage, $description)
478
    {
479
        $this->exampleUsage[$usage] = $description;
480
        return $this;
481
    }
482
483
    /**
484
     * Return the topics for this command.
485
     *
486
     * @return string[]
487
     */
488
    public function getTopics()
489
    {
490
        if (!$this->hasAnnotation('topics')) {
491
            return [];
492
        }
493
        $topics = $this->getAnnotation('topics');
494
        return explode(',', trim($topics));
495
    }
496
497
    /**
498
     * Return the list of refleaction parameters.
499
     *
500
     * @return ReflectionParameter[]
501
     */
502
    public function getParameters()
503
    {
504
        return $this->reflection->getParameters();
505
    }
506
507
    /**
508
     * Descriptions of commandline arguements for this command.
509
     *
510
     * @return DefaultsWithDescriptions
511
     */
512
    public function arguments()
513
    {
514
        return $this->arguments;
515
    }
516
517
    /**
518
     * Descriptions of commandline options for this command.
519
     *
520
     * @return DefaultsWithDescriptions
521
     */
522
    public function options()
523
    {
524
        return $this->options;
525
    }
526
527
    /**
528
     * Get the inputOptions for the options associated with this CommandInfo
529
     * object, e.g. via @option annotations, or from
530
     * $options = ['someoption' => 'defaultvalue'] in the command method
531
     * parameter list.
532
     *
533
     * @return InputOption[]
534
     */
535
    public function inputOptions()
536
    {
537
        if (!isset($this->inputOptions)) {
538
            $this->inputOptions = $this->createInputOptions();
539
        }
540
        return $this->inputOptions;
541
    }
542
543
    protected function createInputOptions()
544
    {
545
        $explicitOptions = [];
546
547
        $opts = $this->options()->getValues();
548
        foreach ($opts as $name => $defaultValue) {
549
            $description = $this->options()->getDescription($name);
550
551
            $fullName = $name;
552
            $shortcut = '';
553
            if (strpos($name, '|')) {
554
                list($fullName, $shortcut) = explode('|', $name, 2);
555
            }
556
557
            if (is_bool($defaultValue)) {
558
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
559
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
560
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
561
            } else {
562
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
563
            }
564
        }
565
566
        return $explicitOptions;
567
    }
568
569
    /**
570
     * An option might have a name such as 'silent|s'. In this
571
     * instance, we will allow the @option or @default tag to
572
     * reference the option only by name (e.g. 'silent' or 's'
573
     * instead of 'silent|s').
574
     *
575
     * @param string $optionName
576
     * @return string
577
     */
578
    public function findMatchingOption($optionName)
579
    {
580
        // Exit fast if there's an exact match
581
        if ($this->options->exists($optionName)) {
582
            return $optionName;
583
        }
584
        $existingOptionName = $this->findExistingOption($optionName);
585
        if (isset($existingOptionName)) {
586
            return $existingOptionName;
587
        }
588
        return $this->findOptionAmongAlternatives($optionName);
589
    }
590
591
    /**
592
     * @param string $optionName
593
     * @return string
594
     */
595
    protected function findOptionAmongAlternatives($optionName)
596
    {
597
        // Check the other direction: if the annotation contains @silent|s
598
        // and the options array has 'silent|s'.
599
        $checkMatching = explode('|', $optionName);
600
        if (count($checkMatching) > 1) {
601
            foreach ($checkMatching as $checkName) {
602
                if ($this->options->exists($checkName)) {
603
                    $this->options->rename($checkName, $optionName);
604
                    return $optionName;
605
                }
606
            }
607
        }
608
        return $optionName;
609
    }
610
611
    /**
612
     * @param string $optionName
613
     * @return string|null
614
     */
615
    protected function findExistingOption($optionName)
616
    {
617
        // Check to see if we can find the option name in an existing option,
618
        // e.g. if the options array has 'silent|s' => false, and the annotation
619
        // is @silent.
620
        foreach ($this->options()->getValues() as $name => $default) {
621
            if (in_array($optionName, explode('|', $name))) {
622
                return $name;
623
            }
624
        }
625
    }
626
627
    /**
628
     * Examine the parameters of the method for this command, and
629
     * build a list of commandline arguements for them.
630
     *
631
     * @return array
632
     */
633
    protected function determineAgumentClassifications()
634
    {
635
        $result = new DefaultsWithDescriptions();
636
        $params = $this->reflection->getParameters();
637
        $optionsFromParameters = $this->determineOptionsFromParameters();
638
        if (!empty($optionsFromParameters)) {
639
            array_pop($params);
640
        }
641
        foreach ($params as $param) {
642
            $this->addParameterToResult($result, $param);
643
        }
644
        return $result;
645
    }
646
647
    /**
648
     * Examine the provided parameter, and determine whether it
649
     * is a parameter that will be filled in with a positional
650
     * commandline argument.
651
     */
652
    protected function addParameterToResult($result, $param)
653
    {
654
        // Commandline arguments must be strings, so ignore any
655
        // parameter that is typehinted to any non-primative class.
656
        if ($param->getClass() != null) {
657
            return;
658
        }
659
        $result->add($param->name);
660
        if ($param->isDefaultValueAvailable()) {
661
            $defaultValue = $param->getDefaultValue();
662
            if (!$this->isAssoc($defaultValue)) {
663
                $result->setDefaultValue($param->name, $defaultValue);
664
            }
665
        } elseif ($param->isArray()) {
666
            $result->setDefaultValue($param->name, []);
667
        }
668
    }
669
670
    /**
671
     * Examine the parameters of the method for this command, and determine
672
     * the disposition of the options from them.
673
     *
674
     * @return array
675
     */
676
    protected function determineOptionsFromParameters()
677
    {
678
        $params = $this->reflection->getParameters();
679
        if (empty($params)) {
680
            return [];
681
        }
682
        $param = end($params);
683
        if (!$param->isDefaultValueAvailable()) {
684
            return [];
685
        }
686
        if (!$this->isAssoc($param->getDefaultValue())) {
687
            return [];
688
        }
689
        return $param->getDefaultValue();
690
    }
691
692
    /**
693
     * Helper; determine if an array is associative or not. An array
694
     * is not associative if its keys are numeric, and numbered sequentially
695
     * from zero. All other arrays are considered to be associative.
696
     *
697
     * @param arrau $arr The array
698
     * @return boolean
699
     */
700
    protected function isAssoc($arr)
701
    {
702
        if (!is_array($arr)) {
703
            return false;
704
        }
705
        return array_keys($arr) !== range(0, count($arr) - 1);
706
    }
707
708
    /**
709
     * Convert from a method name to the corresponding command name. A
710
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
711
     * become 'foo:bar-baz-boz'.
712
     *
713
     * @param string $camel method name.
714
     * @return string
715
     */
716
    protected function convertName($camel)
717
    {
718
        $splitter="-";
719
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
720
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
721
        return strtolower($camel);
722
    }
723
724
    /**
725
     * Parse the docBlock comment for this command, and set the
726
     * fields of this class with the data thereby obtained.
727
     */
728
    protected function parseDocBlock()
729
    {
730
        if (!$this->docBlockIsParsed) {
731
            // The parse function will insert data from the provided method
732
            // into this object, using our accessors.
733
            CommandDocBlockParserFactory::parse($this, $this->reflection);
734
            $this->docBlockIsParsed = true;
735
        }
736
    }
737
738
    /**
739
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
740
     * convert the data into the last of these forms.
741
     */
742
    protected static function convertListToCommaSeparated($text)
743
    {
744
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
745
    }
746
}
747