Completed
Pull Request — master (#66)
by Greg
02:23
created

CommandInfo::deserialize()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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