Completed
Push — master ( 6d71ae...f255f3 )
by Greg
01:49
created

CommandInfo::getInjectedClasses()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 1
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
     * Serialization schema version. Incremented every time the serialization schema changes.
22
     */
23
    const SERIALIZATION_SCHEMA_VERSION = 3;
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
     * @var string[]
93
     */
94
    protected $injectedClasses = [];
95
96
    /**
97
     * Create a new CommandInfo class for a particular method of a class.
98
     *
99
     * @param string|mixed $classNameOrInstance The name of a class, or an
100
     *   instance of it, or an array of cached data.
101
     * @param string $methodName The name of the method to get info about.
102
     * @param array $cache Cached data
103
     * @deprecated Use CommandInfo::create() or CommandInfo::deserialize()
104
     *   instead. In the future, this constructor will be protected.
105
     */
106
    public function __construct($classNameOrInstance, $methodName, $cache = [])
107
    {
108
        $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
109
        $this->methodName = $methodName;
110
        $this->arguments = new DefaultsWithDescriptions();
111
        $this->options = new DefaultsWithDescriptions();
112
113
        // If the cache came from a newer version, ignore it and
114
        // regenerate the cached information.
115
        if (!empty($cache) && CommandInfoDeserializer::isValidSerializedData($cache) && !$this->cachedFileIsModified($cache)) {
116
            $deserializer = new CommandInfoDeserializer();
117
            $deserializer->constructFromCache($this, $cache);
118
            $this->docBlockIsParsed = true;
119
        } else {
120
            $this->constructFromClassAndMethod($classNameOrInstance, $methodName);
121
        }
122
    }
123
124
    public static function create($classNameOrInstance, $methodName)
125
    {
126
        return new self($classNameOrInstance, $methodName);
127
    }
128
129
    public static function deserialize($cache)
130
    {
131
        $cache = (array)$cache;
132
        return new self($cache['class'], $cache['method_name'], $cache);
133
    }
134
135
    public function cachedFileIsModified($cache)
136
    {
137
        $path = $this->reflection->getFileName();
138
        return filemtime($path) != $cache['mtime'];
139
    }
140
141
    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...
142
    {
143
        $this->otherAnnotations = new AnnotationData();
144
        // Set up a default name for the command from the method name.
145
        // This can be overridden via @command or @name annotations.
146
        $this->name = $this->convertName($methodName);
147
        $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
148
        $this->arguments = $this->determineAgumentClassifications();
149
    }
150
151
    /**
152
     * Recover the method name provided to the constructor.
153
     *
154
     * @return string
155
     */
156
    public function getMethodName()
157
    {
158
        return $this->methodName;
159
    }
160
161
    /**
162
     * Return the primary name for this command.
163
     *
164
     * @return string
165
     */
166
    public function getName()
167
    {
168
        $this->parseDocBlock();
169
        return $this->name;
170
    }
171
172
    /**
173
     * Set the primary name for this command.
174
     *
175
     * @param string $name
176
     */
177
    public function setName($name)
178
    {
179
        $this->name = $name;
180
        return $this;
181
    }
182
183
    /**
184
     * Return whether or not this method represents a valid command
185
     * or hook.
186
     */
187
    public function valid()
188
    {
189
        return !empty($this->name);
190
    }
191
192
    /**
193
     * If higher-level code decides that this CommandInfo is not interesting
194
     * or useful (if it is not a command method or a hook method), then
195
     * we will mark it as invalid to prevent it from being created as a command.
196
     * We still cache a placeholder record for invalid methods, so that we
197
     * do not need to re-parse the method again later simply to determine that
198
     * it is invalid.
199
     */
200
    public function invalidate()
201
    {
202
        $this->name = '';
203
    }
204
205
    public function getReturnType()
206
    {
207
        $this->parseDocBlock();
208
        return $this->returnType;
209
    }
210
211
    public function getInjectedClasses()
212
    {
213
        $this->parseDocBlock();
214
        return $this->injectedClasses;
215
    }
216
217
    public function setReturnType($returnType)
218
    {
219
        $this->returnType = $returnType;
220
        return $this;
221
    }
222
223
    /**
224
     * Get any annotations included in the docblock comment for the
225
     * implementation method of this command that are not already
226
     * handled by the primary methods of this class.
227
     *
228
     * @return AnnotationData
229
     */
230
    public function getRawAnnotations()
231
    {
232
        $this->parseDocBlock();
233
        return $this->otherAnnotations;
234
    }
235
236
    /**
237
     * Replace the annotation data.
238
     */
239
    public function replaceRawAnnotations($annotationData)
240
    {
241
        $this->otherAnnotations = new AnnotationData((array) $annotationData);
242
        return $this;
243
    }
244
245
    /**
246
     * Get any annotations included in the docblock comment,
247
     * also including default values such as @command.  We add
248
     * in the default @command annotation late, and only in a
249
     * copy of the annotation data because we use the existance
250
     * of a @command to indicate that this CommandInfo is
251
     * a command, and not a hook or anything else.
252
     *
253
     * @return AnnotationData
254
     */
255
    public function getAnnotations()
256
    {
257
        // Also provide the path to the commandfile that these annotations
258
        // were pulled from and the classname of that file.
259
        $path = $this->reflection->getFileName();
260
        $className = $this->reflection->getDeclaringClass()->getName();
261
        return new AnnotationData(
262
            $this->getRawAnnotations()->getArrayCopy() +
263
            [
264
                'command' => $this->getName(),
265
                '_path' => $path,
266
                '_classname' => $className,
267
            ]
268
        );
269
    }
270
271
    /**
272
     * Return a specific named annotation for this command as a list.
273
     *
274
     * @param string $name The name of the annotation.
275
     * @return array|null
276
     */
277
    public function getAnnotationList($name)
278
    {
279
        // hasAnnotation parses the docblock
280
        if (!$this->hasAnnotation($name)) {
281
            return null;
282
        }
283
        return $this->otherAnnotations->getList($name);
284
        ;
285
    }
286
287
    /**
288
     * Return a specific named annotation for this command as a string.
289
     *
290
     * @param string $name The name of the annotation.
291
     * @return string|null
292
     */
293
    public function getAnnotation($name)
294
    {
295
        // hasAnnotation parses the docblock
296
        if (!$this->hasAnnotation($name)) {
297
            return null;
298
        }
299
        return $this->otherAnnotations->get($name);
300
    }
301
302
    /**
303
     * Check to see if the specified annotation exists for this command.
304
     *
305
     * @param string $annotation The name of the annotation.
306
     * @return boolean
307
     */
308
    public function hasAnnotation($annotation)
309
    {
310
        $this->parseDocBlock();
311
        return isset($this->otherAnnotations[$annotation]);
312
    }
313
314
    /**
315
     * Save any tag that we do not explicitly recognize in the
316
     * 'otherAnnotations' map.
317
     */
318
    public function addAnnotation($name, $content)
319
    {
320
        // Convert to an array and merge if there are multiple
321
        // instances of the same annotation defined.
322
        if (isset($this->otherAnnotations[$name])) {
323
            $content = array_merge((array) $this->otherAnnotations[$name], (array)$content);
324
        }
325
        $this->otherAnnotations[$name] = $content;
326
    }
327
328
    /**
329
     * Remove an annotation that was previoudly set.
330
     */
331
    public function removeAnnotation($name)
332
    {
333
        unset($this->otherAnnotations[$name]);
334
    }
335
336
    /**
337
     * Get the synopsis of the command (~first line).
338
     *
339
     * @return string
340
     */
341
    public function getDescription()
342
    {
343
        $this->parseDocBlock();
344
        return $this->description;
345
    }
346
347
    /**
348
     * Set the command description.
349
     *
350
     * @param string $description The description to set.
351
     */
352
    public function setDescription($description)
353
    {
354
        $this->description = str_replace("\n", ' ', $description);
355
        return $this;
356
    }
357
358
    /**
359
     * Get the help text of the command (the description)
360
     */
361
    public function getHelp()
362
    {
363
        $this->parseDocBlock();
364
        return $this->help;
365
    }
366
    /**
367
     * Set the help text for this command.
368
     *
369
     * @param string $help The help text.
370
     */
371
    public function setHelp($help)
372
    {
373
        $this->help = $help;
374
        return $this;
375
    }
376
377
    /**
378
     * Return the list of aliases for this command.
379
     * @return string[]
380
     */
381
    public function getAliases()
382
    {
383
        $this->parseDocBlock();
384
        return $this->aliases;
385
    }
386
387
    /**
388
     * Set aliases that can be used in place of the command's primary name.
389
     *
390
     * @param string|string[] $aliases
391
     */
392
    public function setAliases($aliases)
393
    {
394
        if (is_string($aliases)) {
395
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
396
        }
397
        $this->aliases = array_filter($aliases);
398
        return $this;
399
    }
400
401
    /**
402
     * Get hidden status for the command.
403
     * @return bool
404
     */
405
    public function getHidden()
406
    {
407
        $this->parseDocBlock();
408
        return $this->hasAnnotation('hidden');
409
    }
410
411
    /**
412
     * Set hidden status. List command omits hidden commands.
413
     *
414
     * @param bool $hidden
415
     */
416
    public function setHidden($hidden)
417
    {
418
        $this->hidden = $hidden;
0 ignored issues
show
Bug introduced by
The property hidden does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
419
        return $this;
420
    }
421
422
    /**
423
     * Return the examples for this command. This is @usage instead of
424
     * @example because the later is defined by the phpdoc standard to
425
     * be example method calls.
426
     *
427
     * @return string[]
428
     */
429
    public function getExampleUsages()
430
    {
431
        $this->parseDocBlock();
432
        return $this->exampleUsage;
433
    }
434
435
    /**
436
     * Add an example usage for this command.
437
     *
438
     * @param string $usage An example of the command, including the command
439
     *   name and all of its example arguments and options.
440
     * @param string $description An explanation of what the example does.
441
     */
442
    public function setExampleUsage($usage, $description)
443
    {
444
        $this->exampleUsage[$usage] = $description;
445
        return $this;
446
    }
447
448
    /**
449
     * Overwrite all example usages
450
     */
451
    public function replaceExampleUsages($usages)
452
    {
453
        $this->exampleUsage = $usages;
454
        return $this;
455
    }
456
457
    /**
458
     * Return the topics for this command.
459
     *
460
     * @return string[]
461
     */
462
    public function getTopics()
463
    {
464
        if (!$this->hasAnnotation('topics')) {
465
            return [];
466
        }
467
        $topics = $this->getAnnotation('topics');
468
        return explode(',', trim($topics));
469
    }
470
471
    /**
472
     * Return the list of refleaction parameters.
473
     *
474
     * @return ReflectionParameter[]
475
     */
476
    public function getParameters()
477
    {
478
        return $this->reflection->getParameters();
479
    }
480
481
    /**
482
     * Descriptions of commandline arguements for this command.
483
     *
484
     * @return DefaultsWithDescriptions
485
     */
486
    public function arguments()
487
    {
488
        return $this->arguments;
489
    }
490
491
    /**
492
     * Descriptions of commandline options for this command.
493
     *
494
     * @return DefaultsWithDescriptions
495
     */
496
    public function options()
497
    {
498
        return $this->options;
499
    }
500
501
    /**
502
     * Get the inputOptions for the options associated with this CommandInfo
503
     * object, e.g. via @option annotations, or from
504
     * $options = ['someoption' => 'defaultvalue'] in the command method
505
     * parameter list.
506
     *
507
     * @return InputOption[]
508
     */
509
    public function inputOptions()
510
    {
511
        if (!isset($this->inputOptions)) {
512
            $this->inputOptions = $this->createInputOptions();
513
        }
514
        return $this->inputOptions;
515
    }
516
517
    protected function addImplicitNoOptions()
518
    {
519
        $opts = $this->options()->getValues();
520
        foreach ($opts as $name => $defaultValue) {
521
            if ($defaultValue === true) {
522
                $key = 'no-' . $name;
523
                if (!array_key_exists($key, $opts)) {
524
                    $description = "Negate --$name option.";
525
                    $this->options()->add($key, $description, false);
526
                }
527
            }
528
        }
529
    }
530
531
    protected function createInputOptions()
532
    {
533
        $explicitOptions = [];
534
        $this->addImplicitNoOptions();
535
536
        $opts = $this->options()->getValues();
537
        foreach ($opts as $name => $defaultValue) {
538
            $description = $this->options()->getDescription($name);
539
540
            $fullName = $name;
541
            $shortcut = '';
542
            if (strpos($name, '|')) {
543
                list($fullName, $shortcut) = explode('|', $name, 2);
544
            }
545
546
            // Treat the following two cases identically:
547
            //   - 'foo' => InputOption::VALUE_OPTIONAL
548
            //   - 'foo' => null
549
            // The first form is preferred, but we will convert the value
550
            // to 'null' for storage as the option default value.
551
            if ($defaultValue === InputOption::VALUE_OPTIONAL) {
552
                $defaultValue = null;
553
            }
554
555
            if ($defaultValue === false) {
556
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
557
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
558
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
559
            } elseif (is_array($defaultValue)) {
560
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
561
                $explicitOptions[$fullName] = new InputOption(
562
                    $fullName,
563
                    $shortcut,
564
                    InputOption::VALUE_IS_ARRAY | $optionality,
565
                    $description,
566
                    count($defaultValue) ? $defaultValue : null
0 ignored issues
show
Bug introduced by
It seems like count($defaultValue) ? $defaultValue : null can also be of type array; however, Symfony\Component\Consol...utOption::__construct() does only seem to accept string|array<integer,string>|integer|boolean|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
567
                );
568
            } else {
569
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
570
            }
571
        }
572
573
        return $explicitOptions;
574
    }
575
576
    /**
577
     * An option might have a name such as 'silent|s'. In this
578
     * instance, we will allow the @option or @default tag to
579
     * reference the option only by name (e.g. 'silent' or 's'
580
     * instead of 'silent|s').
581
     *
582
     * @param string $optionName
583
     * @return string
584
     */
585
    public function findMatchingOption($optionName)
586
    {
587
        // Exit fast if there's an exact match
588
        if ($this->options->exists($optionName)) {
589
            return $optionName;
590
        }
591
        $existingOptionName = $this->findExistingOption($optionName);
592
        if (isset($existingOptionName)) {
593
            return $existingOptionName;
594
        }
595
        return $this->findOptionAmongAlternatives($optionName);
596
    }
597
598
    /**
599
     * @param string $optionName
600
     * @return string
601
     */
602
    protected function findOptionAmongAlternatives($optionName)
603
    {
604
        // Check the other direction: if the annotation contains @silent|s
605
        // and the options array has 'silent|s'.
606
        $checkMatching = explode('|', $optionName);
607
        if (count($checkMatching) > 1) {
608
            foreach ($checkMatching as $checkName) {
609
                if ($this->options->exists($checkName)) {
610
                    $this->options->rename($checkName, $optionName);
611
                    return $optionName;
612
                }
613
            }
614
        }
615
        return $optionName;
616
    }
617
618
    /**
619
     * @param string $optionName
620
     * @return string|null
621
     */
622
    protected function findExistingOption($optionName)
623
    {
624
        // Check to see if we can find the option name in an existing option,
625
        // e.g. if the options array has 'silent|s' => false, and the annotation
626
        // is @silent.
627
        foreach ($this->options()->getValues() as $name => $default) {
628
            if (in_array($optionName, explode('|', $name))) {
629
                return $name;
630
            }
631
        }
632
    }
633
634
    /**
635
     * Examine the parameters of the method for this command, and
636
     * build a list of commandline arguements for them.
637
     *
638
     * @return array
639
     */
640
    protected function determineAgumentClassifications()
641
    {
642
        $result = new DefaultsWithDescriptions();
643
        $params = $this->reflection->getParameters();
644
        $optionsFromParameters = $this->determineOptionsFromParameters();
0 ignored issues
show
Unused Code introduced by
$optionsFromParameters is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
645
        if ($this->lastParameterIsOptionsArray()) {
646
            array_pop($params);
647
        }
648
        while (!empty($params) && ($params[0]->getClass() != null)) {
649
            $param = array_shift($params);
650
            $injectedClass = $param->getClass()->getName();
651
            array_unshift($this->injectedClasses, $injectedClass);
652
        }
653
        foreach ($params as $param) {
654
            $this->addParameterToResult($result, $param);
655
        }
656
        return $result;
657
    }
658
659
    /**
660
     * Examine the provided parameter, and determine whether it
661
     * is a parameter that will be filled in with a positional
662
     * commandline argument.
663
     */
664
    protected function addParameterToResult($result, $param)
665
    {
666
        // Commandline arguments must be strings, so ignore any
667
        // parameter that is typehinted to any non-primative class.
668
        if ($param->getClass() != null) {
669
            return;
670
        }
671
        $result->add($param->name);
672
        if ($param->isDefaultValueAvailable()) {
673
            $defaultValue = $param->getDefaultValue();
674
            if (!$this->isAssoc($defaultValue)) {
675
                $result->setDefaultValue($param->name, $defaultValue);
676
            }
677
        } elseif ($param->isArray()) {
678
            $result->setDefaultValue($param->name, []);
679
        }
680
    }
681
682
    /**
683
     * Examine the parameters of the method for this command, and determine
684
     * the disposition of the options from them.
685
     *
686
     * @return array
687
     */
688
    protected function determineOptionsFromParameters()
689
    {
690
        $params = $this->reflection->getParameters();
691
        if (empty($params)) {
692
            return [];
693
        }
694
        $param = end($params);
695
        if (!$param->isDefaultValueAvailable()) {
696
            return [];
697
        }
698
        if (!$this->isAssoc($param->getDefaultValue())) {
699
            return [];
700
        }
701
        return $param->getDefaultValue();
702
    }
703
704
    /**
705
     * Determine if the last argument contains $options.
706
     *
707
     * Two forms indicate options:
708
     * - $options = []
709
     * - $options = ['flag' => 'default-value']
710
     *
711
     * Any other form, including `array $foo`, is not options.
712
     */
713
    protected function lastParameterIsOptionsArray()
714
    {
715
        $params = $this->reflection->getParameters();
716
        if (empty($params)) {
717
            return [];
718
        }
719
        $param = end($params);
720
        if (!$param->isDefaultValueAvailable()) {
721
            return [];
722
        }
723
        return is_array($param->getDefaultValue());
724
    }
725
726
    /**
727
     * Helper; determine if an array is associative or not. An array
728
     * is not associative if its keys are numeric, and numbered sequentially
729
     * from zero. All other arrays are considered to be associative.
730
     *
731
     * @param array $arr The array
732
     * @return boolean
733
     */
734
    protected function isAssoc($arr)
735
    {
736
        if (!is_array($arr)) {
737
            return false;
738
        }
739
        return array_keys($arr) !== range(0, count($arr) - 1);
740
    }
741
742
    /**
743
     * Convert from a method name to the corresponding command name. A
744
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
745
     * become 'foo:bar-baz-boz'.
746
     *
747
     * @param string $camel method name.
748
     * @return string
749
     */
750
    protected function convertName($camel)
751
    {
752
        $splitter="-";
753
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
754
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
755
        return strtolower($camel);
756
    }
757
758
    /**
759
     * Parse the docBlock comment for this command, and set the
760
     * fields of this class with the data thereby obtained.
761
     */
762
    protected function parseDocBlock()
763
    {
764
        if (!$this->docBlockIsParsed) {
765
            // The parse function will insert data from the provided method
766
            // into this object, using our accessors.
767
            CommandDocBlockParserFactory::parse($this, $this->reflection);
768
            $this->docBlockIsParsed = true;
769
        }
770
    }
771
772
    /**
773
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
774
     * convert the data into the last of these forms.
775
     */
776
    protected static function convertListToCommaSeparated($text)
777
    {
778
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
779
    }
780
}
781