Completed
Pull Request — master (#119)
by Greg
01:51
created

CommandInfo::convertName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 5
nc 1
nop 1
1
<?php
2
namespace Consolidation\AnnotatedCommand\Parser;
3
4
use Symfony\Component\Console\Input\InputOption;
5
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
6
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
7
use Consolidation\AnnotatedCommand\AnnotationData;
8
9
/**
10
 * Given a class and method name, parse the annotations in the
11
 * DocBlock comment, and provide accessor methods for all of
12
 * the elements that are needed to create a Symfony Console Command.
13
 *
14
 * Note that the name of this class is now somewhat of a misnomer,
15
 * as we now use it to hold annotation data for hooks as well as commands.
16
 * It would probably be better to rename this to MethodInfo at some point.
17
 */
18
class CommandInfo
19
{
20
    /**
21
     * Serialization schema version. Incremented every time the serialization schema changes.
22
     */
23
    const SERIALIZATION_SCHEMA_VERSION = 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
     * Return the examples for this command. This is @usage instead of
392
     * @example because the later is defined by the phpdoc standard to
393
     * be example method calls.
394
     *
395
     * @return string[]
396
     */
397
    public function getExampleUsages()
398
    {
399
        $this->parseDocBlock();
400
        return $this->exampleUsage;
401
    }
402
403
    /**
404
     * Add an example usage for this command.
405
     *
406
     * @param string $usage An example of the command, including the command
407
     *   name and all of its example arguments and options.
408
     * @param string $description An explanation of what the example does.
409
     */
410
    public function setExampleUsage($usage, $description)
411
    {
412
        $this->exampleUsage[$usage] = $description;
413
        return $this;
414
    }
415
416
    /**
417
     * Overwrite all example usages
418
     */
419
    public function replaceExampleUsages($usages)
420
    {
421
        $this->exampleUsage = $usages;
422
        return $this;
423
    }
424
425
    /**
426
     * Return the topics for this command.
427
     *
428
     * @return string[]
429
     */
430
    public function getTopics()
431
    {
432
        if (!$this->hasAnnotation('topics')) {
433
            return [];
434
        }
435
        $topics = $this->getAnnotation('topics');
436
        return explode(',', trim($topics));
437
    }
438
439
    /**
440
     * Return the list of refleaction parameters.
441
     *
442
     * @return ReflectionParameter[]
443
     */
444
    public function getParameters()
445
    {
446
        return $this->reflection->getParameters();
447
    }
448
449
    /**
450
     * Descriptions of commandline arguements for this command.
451
     *
452
     * @return DefaultsWithDescriptions
453
     */
454
    public function arguments()
455
    {
456
        return $this->arguments;
457
    }
458
459
    /**
460
     * Descriptions of commandline options for this command.
461
     *
462
     * @return DefaultsWithDescriptions
463
     */
464
    public function options()
465
    {
466
        return $this->options;
467
    }
468
469
    /**
470
     * Get the inputOptions for the options associated with this CommandInfo
471
     * object, e.g. via @option annotations, or from
472
     * $options = ['someoption' => 'defaultvalue'] in the command method
473
     * parameter list.
474
     *
475
     * @return InputOption[]
476
     */
477
    public function inputOptions()
478
    {
479
        if (!isset($this->inputOptions)) {
480
            $this->inputOptions = $this->createInputOptions();
481
        }
482
        return $this->inputOptions;
483
    }
484
485
    protected function addImplicitNoOptions()
486
    {
487
        $opts = $this->options()->getValues();
488
        foreach ($opts as $name => $defaultValue) {
489
            if ($defaultValue === true) {
490
                $key = 'no-' . $name;
491
                if (!array_key_exists($key, $opts)) {
492
                    $description = "Negate --$name option.";
493
                    $this->options()->add($key, $description, false);
494
                }
495
            }
496
        }
497
    }
498
499
    protected function createInputOptions()
500
    {
501
        $explicitOptions = [];
502
        $this->addImplicitNoOptions();
503
504
        $opts = $this->options()->getValues();
505
        foreach ($opts as $name => $defaultValue) {
506
            $description = $this->options()->getDescription($name);
507
508
            $fullName = $name;
509
            $shortcut = '';
510
            if (strpos($name, '|')) {
511
                list($fullName, $shortcut) = explode('|', $name, 2);
512
            }
513
514
            // Treat the following three cases identically:
515
            //   - 'foo' => InputOption::VALUE_OPTIONAL
516
            //   - 'foo' => true
517
            //   - 'foo' => null
518
            // The first form is preferred, but we will convert all
519
            // forms to 'null' for storage as the option default value.
520
            if ($defaultValue === InputOption::VALUE_OPTIONAL) {
521
                $defaultValue = null;
522
            }
523
524
            if ($defaultValue === false) {
525
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
526
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
527
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
528
            } elseif (is_array($defaultValue)) {
529
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
530
                $explicitOptions[$fullName] = new InputOption(
531
                    $fullName,
532
                    $shortcut,
533
                    InputOption::VALUE_IS_ARRAY | $optionality,
534
                    $description,
535
                    count($defaultValue) ? $defaultValue : null
536
                );
537
            } else {
538
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
539
            }
540
        }
541
542
        return $explicitOptions;
543
    }
544
545
    /**
546
     * An option might have a name such as 'silent|s'. In this
547
     * instance, we will allow the @option or @default tag to
548
     * reference the option only by name (e.g. 'silent' or 's'
549
     * instead of 'silent|s').
550
     *
551
     * @param string $optionName
552
     * @return string
553
     */
554
    public function findMatchingOption($optionName)
555
    {
556
        // Exit fast if there's an exact match
557
        if ($this->options->exists($optionName)) {
558
            return $optionName;
559
        }
560
        $existingOptionName = $this->findExistingOption($optionName);
561
        if (isset($existingOptionName)) {
562
            return $existingOptionName;
563
        }
564
        return $this->findOptionAmongAlternatives($optionName);
565
    }
566
567
    /**
568
     * @param string $optionName
569
     * @return string
570
     */
571
    protected function findOptionAmongAlternatives($optionName)
572
    {
573
        // Check the other direction: if the annotation contains @silent|s
574
        // and the options array has 'silent|s'.
575
        $checkMatching = explode('|', $optionName);
576
        if (count($checkMatching) > 1) {
577
            foreach ($checkMatching as $checkName) {
578
                if ($this->options->exists($checkName)) {
579
                    $this->options->rename($checkName, $optionName);
580
                    return $optionName;
581
                }
582
            }
583
        }
584
        return $optionName;
585
    }
586
587
    /**
588
     * @param string $optionName
589
     * @return string|null
590
     */
591
    protected function findExistingOption($optionName)
592
    {
593
        // Check to see if we can find the option name in an existing option,
594
        // e.g. if the options array has 'silent|s' => false, and the annotation
595
        // is @silent.
596
        foreach ($this->options()->getValues() as $name => $default) {
597
            if (in_array($optionName, explode('|', $name))) {
598
                return $name;
599
            }
600
        }
601
    }
602
603
    /**
604
     * Examine the parameters of the method for this command, and
605
     * build a list of commandline arguements for them.
606
     *
607
     * @return array
608
     */
609
    protected function determineAgumentClassifications()
610
    {
611
        $result = new DefaultsWithDescriptions();
612
        $params = $this->reflection->getParameters();
613
        $optionsFromParameters = $this->determineOptionsFromParameters();
614
        if (!empty($optionsFromParameters)) {
615
            array_pop($params);
616
        }
617
        foreach ($params as $param) {
618
            $this->addParameterToResult($result, $param);
619
        }
620
        return $result;
621
    }
622
623
    /**
624
     * Examine the provided parameter, and determine whether it
625
     * is a parameter that will be filled in with a positional
626
     * commandline argument.
627
     */
628
    protected function addParameterToResult($result, $param)
629
    {
630
        // Commandline arguments must be strings, so ignore any
631
        // parameter that is typehinted to any non-primative class.
632
        if ($param->getClass() != null) {
633
            return;
634
        }
635
        $result->add($param->name);
636
        if ($param->isDefaultValueAvailable()) {
637
            $defaultValue = $param->getDefaultValue();
638
            if (!$this->isAssoc($defaultValue)) {
639
                $result->setDefaultValue($param->name, $defaultValue);
640
            }
641
        } elseif ($param->isArray()) {
642
            $result->setDefaultValue($param->name, []);
643
        }
644
    }
645
646
    /**
647
     * Examine the parameters of the method for this command, and determine
648
     * the disposition of the options from them.
649
     *
650
     * @return array
651
     */
652
    protected function determineOptionsFromParameters()
653
    {
654
        $params = $this->reflection->getParameters();
655
        if (empty($params)) {
656
            return [];
657
        }
658
        $param = end($params);
659
        if (!$param->isDefaultValueAvailable()) {
660
            return [];
661
        }
662
        if (!$this->isAssoc($param->getDefaultValue())) {
663
            return [];
664
        }
665
        return $param->getDefaultValue();
666
    }
667
668
    /**
669
     * Helper; determine if an array is associative or not. An array
670
     * is not associative if its keys are numeric, and numbered sequentially
671
     * from zero. All other arrays are considered to be associative.
672
     *
673
     * @param array $arr The array
674
     * @return boolean
675
     */
676
    protected function isAssoc($arr)
677
    {
678
        if (!is_array($arr)) {
679
            return false;
680
        }
681
        return array_keys($arr) !== range(0, count($arr) - 1);
682
    }
683
684
    /**
685
     * Convert from a method name to the corresponding command name. A
686
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
687
     * become 'foo:bar-baz-boz'.
688
     *
689
     * @param string $camel method name.
690
     * @return string
691
     */
692
    protected function convertName($camel)
693
    {
694
        $splitter="-";
695
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
696
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
697
        return strtolower($camel);
698
    }
699
700
    /**
701
     * Parse the docBlock comment for this command, and set the
702
     * fields of this class with the data thereby obtained.
703
     */
704
    protected function parseDocBlock()
705
    {
706
        if (!$this->docBlockIsParsed) {
707
            // The parse function will insert data from the provided method
708
            // into this object, using our accessors.
709
            CommandDocBlockParserFactory::parse($this, $this->reflection);
710
            $this->docBlockIsParsed = true;
711
        }
712
    }
713
714
    /**
715
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
716
     * convert the data into the last of these forms.
717
     */
718
    protected static function convertListToCommaSeparated($text)
719
    {
720
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
721
    }
722
}
723