Completed
Push — master ( d13e00...103215 )
by Greg
02:14
created

CommandInfo::getAnnotation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 4
nc 2
nop 1
1
<?php
2
namespace Consolidation\AnnotatedCommand\Parser;
3
4
use Symfony\Component\Console\Input\InputOption;
5
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
6
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
7
use Consolidation\AnnotatedCommand\AnnotationData;
8
9
/**
10
 * Given a class and method name, parse the annotations in the
11
 * DocBlock comment, and provide accessor methods for all of
12
 * the elements that are needed to create a Symfony Console Command.
13
 *
14
 * Note that the name of this class is now somewhat of a misnomer,
15
 * as we now use it to hold annotation data for hooks as well as commands.
16
 * It would probably be better to rename this to MethodInfo at some point.
17
 */
18
class CommandInfo
19
{
20
    /**
21
     * Serialization schema version. Incremented every time the serialization schema changes.
22
     */
23
    const SERIALIZATION_SCHEMA_VERSION = 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 createInputOptions()
486
    {
487
        $explicitOptions = [];
488
489
        $opts = $this->options()->getValues();
490
        foreach ($opts as $name => $defaultValue) {
491
            $description = $this->options()->getDescription($name);
492
493
            $fullName = $name;
494
            $shortcut = '';
495
            if (strpos($name, '|')) {
496
                list($fullName, $shortcut) = explode('|', $name, 2);
497
            }
498
499
            if (is_bool($defaultValue)) {
500
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
501
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
502
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
503
            } elseif (is_array($defaultValue)) {
504
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
505
                $explicitOptions[$fullName] = new InputOption(
506
                    $fullName,
507
                    $shortcut,
508
                    InputOption::VALUE_IS_ARRAY | $optionality,
509
                    $description,
510
                    count($defaultValue) ? $defaultValue : null
511
                );
512
            } else {
513
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
514
            }
515
        }
516
517
        return $explicitOptions;
518
    }
519
520
    /**
521
     * An option might have a name such as 'silent|s'. In this
522
     * instance, we will allow the @option or @default tag to
523
     * reference the option only by name (e.g. 'silent' or 's'
524
     * instead of 'silent|s').
525
     *
526
     * @param string $optionName
527
     * @return string
528
     */
529
    public function findMatchingOption($optionName)
530
    {
531
        // Exit fast if there's an exact match
532
        if ($this->options->exists($optionName)) {
533
            return $optionName;
534
        }
535
        $existingOptionName = $this->findExistingOption($optionName);
536
        if (isset($existingOptionName)) {
537
            return $existingOptionName;
538
        }
539
        return $this->findOptionAmongAlternatives($optionName);
540
    }
541
542
    /**
543
     * @param string $optionName
544
     * @return string
545
     */
546
    protected function findOptionAmongAlternatives($optionName)
547
    {
548
        // Check the other direction: if the annotation contains @silent|s
549
        // and the options array has 'silent|s'.
550
        $checkMatching = explode('|', $optionName);
551
        if (count($checkMatching) > 1) {
552
            foreach ($checkMatching as $checkName) {
553
                if ($this->options->exists($checkName)) {
554
                    $this->options->rename($checkName, $optionName);
555
                    return $optionName;
556
                }
557
            }
558
        }
559
        return $optionName;
560
    }
561
562
    /**
563
     * @param string $optionName
564
     * @return string|null
565
     */
566
    protected function findExistingOption($optionName)
567
    {
568
        // Check to see if we can find the option name in an existing option,
569
        // e.g. if the options array has 'silent|s' => false, and the annotation
570
        // is @silent.
571
        foreach ($this->options()->getValues() as $name => $default) {
572
            if (in_array($optionName, explode('|', $name))) {
573
                return $name;
574
            }
575
        }
576
    }
577
578
    /**
579
     * Examine the parameters of the method for this command, and
580
     * build a list of commandline arguements for them.
581
     *
582
     * @return array
583
     */
584
    protected function determineAgumentClassifications()
585
    {
586
        $result = new DefaultsWithDescriptions();
587
        $params = $this->reflection->getParameters();
588
        $optionsFromParameters = $this->determineOptionsFromParameters();
589
        if (!empty($optionsFromParameters)) {
590
            array_pop($params);
591
        }
592
        foreach ($params as $param) {
593
            $this->addParameterToResult($result, $param);
594
        }
595
        return $result;
596
    }
597
598
    /**
599
     * Examine the provided parameter, and determine whether it
600
     * is a parameter that will be filled in with a positional
601
     * commandline argument.
602
     */
603
    protected function addParameterToResult($result, $param)
604
    {
605
        // Commandline arguments must be strings, so ignore any
606
        // parameter that is typehinted to any non-primative class.
607
        if ($param->getClass() != null) {
608
            return;
609
        }
610
        $result->add($param->name);
611
        if ($param->isDefaultValueAvailable()) {
612
            $defaultValue = $param->getDefaultValue();
613
            if (!$this->isAssoc($defaultValue)) {
614
                $result->setDefaultValue($param->name, $defaultValue);
615
            }
616
        } elseif ($param->isArray()) {
617
            $result->setDefaultValue($param->name, []);
618
        }
619
    }
620
621
    /**
622
     * Examine the parameters of the method for this command, and determine
623
     * the disposition of the options from them.
624
     *
625
     * @return array
626
     */
627
    protected function determineOptionsFromParameters()
628
    {
629
        $params = $this->reflection->getParameters();
630
        if (empty($params)) {
631
            return [];
632
        }
633
        $param = end($params);
634
        if (!$param->isDefaultValueAvailable()) {
635
            return [];
636
        }
637
        if (!$this->isAssoc($param->getDefaultValue())) {
638
            return [];
639
        }
640
        return $param->getDefaultValue();
641
    }
642
643
    /**
644
     * Helper; determine if an array is associative or not. An array
645
     * is not associative if its keys are numeric, and numbered sequentially
646
     * from zero. All other arrays are considered to be associative.
647
     *
648
     * @param array $arr The array
649
     * @return boolean
650
     */
651
    protected function isAssoc($arr)
652
    {
653
        if (!is_array($arr)) {
654
            return false;
655
        }
656
        return array_keys($arr) !== range(0, count($arr) - 1);
657
    }
658
659
    /**
660
     * Convert from a method name to the corresponding command name. A
661
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
662
     * become 'foo:bar-baz-boz'.
663
     *
664
     * @param string $camel method name.
665
     * @return string
666
     */
667
    protected function convertName($camel)
668
    {
669
        $splitter="-";
670
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
671
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
672
        return strtolower($camel);
673
    }
674
675
    /**
676
     * Parse the docBlock comment for this command, and set the
677
     * fields of this class with the data thereby obtained.
678
     */
679
    protected function parseDocBlock()
680
    {
681
        if (!$this->docBlockIsParsed) {
682
            // The parse function will insert data from the provided method
683
            // into this object, using our accessors.
684
            CommandDocBlockParserFactory::parse($this, $this->reflection);
685
            $this->docBlockIsParsed = true;
686
        }
687
    }
688
689
    /**
690
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
691
     * convert the data into the last of these forms.
692
     */
693
    protected static function convertListToCommaSeparated($text)
694
    {
695
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
696
    }
697
}
698