Completed
Push — master ( f7c614...07d3c8 )
by Greg
11s
created

CommandInfo::lastParameterIsOptionsArray()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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