Completed
Push — master ( 27ec94...80d1a0 )
by Greg
02:14
created

CommandInfo::serializeDefaultsWithDescriptions()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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