Completed
Pull Request — master (#98)
by Greg
02:19
created

CommandInfo::addAnnotation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
cc 2
eloc 4
nc 2
nop 2
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.
262
     *
263
     * @param string $annotation The name of the annotation.
264
     * @return string
265
     */
266 View Code Duplication
    public function getAnnotation($annotation)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
267
    {
268
        $value = $this->getAnnotationData($annotation);
269
        if (is_array($value)) {
270
            $value = $this->csvEscape($value);
271
        }
272
        return $value;
273
    }
274
275
    /**
276
     * Convert a string to a csv.
277
     */
278
    protected function csvEscape($data, $delimiter = ',')
279
    {
280
        $buffer = fopen('php://temp', 'r+');
281
        fputcsv($buffer, $data, $delimiter);
282
        rewind($buffer);
283
        $csv = fgets($buffer);
284
        fclose($buffer);
285
        return rtrim($csv);
286
    }
287
288
    /**
289
     * Return a specific named annotation for this command.
290
     *
291
     * @param string $annotation The name of the annotation.
292
     * @return string
293
     */
294 View Code Duplication
    public function getAnnotationList($annotation)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
295
    {
296
        $value = $this->getAnnotationData($annotation);
297
        if (!is_array($value)) {
298
            $value = str_getcsv($value);
299
        }
300
        return $value;
301
    }
302
303
    /**
304
     * Return a specific named annotation for this command.
305
     *
306
     * @param string $annotation The name of the annotation.
307
     * @return string
308
     */
309
    protected function getAnnotationData($annotation)
310
    {
311
        // hasAnnotation parses the docblock
312
        if (!$this->hasAnnotation($annotation)) {
313
            return null;
314
        }
315
        return $this->otherAnnotations[$annotation];
316
    }
317
318
    /**
319
     * Check to see if the specified annotation exists for this command.
320
     *
321
     * @param string $annotation The name of the annotation.
322
     * @return boolean
323
     */
324
    public function hasAnnotation($annotation)
325
    {
326
        $this->parseDocBlock();
327
        return isset($this->otherAnnotations[$annotation]);
328
    }
329
330
    /**
331
     * Save any tag that we do not explicitly recognize in the
332
     * 'otherAnnotations' map.
333
     */
334
    public function addAnnotation($name, $content)
335
    {
336
        // Convert to an array and merge if there are multiple
337
        // instances of the same annotation defined.
338
        if (isset($this->otherAnnotations[$name])) {
339
            $content = array_merge((array) $this->otherAnnotations[$name], (array)$content);
340
        }
341
        $this->otherAnnotations[$name] = $content;
342
    }
343
344
    /**
345
     * Remove an annotation that was previoudly set.
346
     */
347
    public function removeAnnotation($name)
348
    {
349
        unset($this->otherAnnotations[$name]);
350
    }
351
352
    /**
353
     * Get the synopsis of the command (~first line).
354
     *
355
     * @return string
356
     */
357
    public function getDescription()
358
    {
359
        $this->parseDocBlock();
360
        return $this->description;
361
    }
362
363
    /**
364
     * Set the command description.
365
     *
366
     * @param string $description The description to set.
367
     */
368
    public function setDescription($description)
369
    {
370
        $this->description = str_replace("\n", ' ', $description);
371
        return $this;
372
    }
373
374
    /**
375
     * Get the help text of the command (the description)
376
     */
377
    public function getHelp()
378
    {
379
        $this->parseDocBlock();
380
        return $this->help;
381
    }
382
    /**
383
     * Set the help text for this command.
384
     *
385
     * @param string $help The help text.
386
     */
387
    public function setHelp($help)
388
    {
389
        $this->help = $help;
390
        return $this;
391
    }
392
393
    /**
394
     * Return the list of aliases for this command.
395
     * @return string[]
396
     */
397
    public function getAliases()
398
    {
399
        $this->parseDocBlock();
400
        return $this->aliases;
401
    }
402
403
    /**
404
     * Set aliases that can be used in place of the command's primary name.
405
     *
406
     * @param string|string[] $aliases
407
     */
408
    public function setAliases($aliases)
409
    {
410
        if (is_string($aliases)) {
411
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
412
        }
413
        $this->aliases = array_filter($aliases);
414
        return $this;
415
    }
416
417
    /**
418
     * Return the examples for this command. This is @usage instead of
419
     * @example because the later is defined by the phpdoc standard to
420
     * be example method calls.
421
     *
422
     * @return string[]
423
     */
424
    public function getExampleUsages()
425
    {
426
        $this->parseDocBlock();
427
        return $this->exampleUsage;
428
    }
429
430
    /**
431
     * Add an example usage for this command.
432
     *
433
     * @param string $usage An example of the command, including the command
434
     *   name and all of its example arguments and options.
435
     * @param string $description An explanation of what the example does.
436
     */
437
    public function setExampleUsage($usage, $description)
438
    {
439
        $this->exampleUsage[$usage] = $description;
440
        return $this;
441
    }
442
443
    /**
444
     * Overwrite all example usages
445
     */
446
    public function replaceExampleUsages($usages)
447
    {
448
        $this->exampleUsage = $usages;
449
        return $this;
450
    }
451
452
    /**
453
     * Return the topics for this command.
454
     *
455
     * @return string[]
456
     */
457
    public function getTopics()
458
    {
459
        if (!$this->hasAnnotation('topics')) {
460
            return [];
461
        }
462
        $topics = $this->getAnnotation('topics');
463
        return explode(',', trim($topics));
464
    }
465
466
    /**
467
     * Return the list of refleaction parameters.
468
     *
469
     * @return ReflectionParameter[]
470
     */
471
    public function getParameters()
472
    {
473
        return $this->reflection->getParameters();
474
    }
475
476
    /**
477
     * Descriptions of commandline arguements for this command.
478
     *
479
     * @return DefaultsWithDescriptions
480
     */
481
    public function arguments()
482
    {
483
        return $this->arguments;
484
    }
485
486
    /**
487
     * Descriptions of commandline options for this command.
488
     *
489
     * @return DefaultsWithDescriptions
490
     */
491
    public function options()
492
    {
493
        return $this->options;
494
    }
495
496
    /**
497
     * Get the inputOptions for the options associated with this CommandInfo
498
     * object, e.g. via @option annotations, or from
499
     * $options = ['someoption' => 'defaultvalue'] in the command method
500
     * parameter list.
501
     *
502
     * @return InputOption[]
503
     */
504
    public function inputOptions()
505
    {
506
        if (!isset($this->inputOptions)) {
507
            $this->inputOptions = $this->createInputOptions();
508
        }
509
        return $this->inputOptions;
510
    }
511
512
    protected function createInputOptions()
513
    {
514
        $explicitOptions = [];
515
516
        $opts = $this->options()->getValues();
517
        foreach ($opts as $name => $defaultValue) {
518
            $description = $this->options()->getDescription($name);
519
520
            $fullName = $name;
521
            $shortcut = '';
522
            if (strpos($name, '|')) {
523
                list($fullName, $shortcut) = explode('|', $name, 2);
524
            }
525
526
            if (is_bool($defaultValue)) {
527
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
528
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
529
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
530
            } elseif (is_array($defaultValue)) {
531
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
532
                $explicitOptions[$fullName] = new InputOption(
533
                    $fullName,
534
                    $shortcut,
535
                    InputOption::VALUE_IS_ARRAY | $optionality,
536
                    $description,
537
                    count($defaultValue) ? $defaultValue : null
538
                );
539
            } else {
540
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
541
            }
542
        }
543
544
        return $explicitOptions;
545
    }
546
547
    /**
548
     * An option might have a name such as 'silent|s'. In this
549
     * instance, we will allow the @option or @default tag to
550
     * reference the option only by name (e.g. 'silent' or 's'
551
     * instead of 'silent|s').
552
     *
553
     * @param string $optionName
554
     * @return string
555
     */
556
    public function findMatchingOption($optionName)
557
    {
558
        // Exit fast if there's an exact match
559
        if ($this->options->exists($optionName)) {
560
            return $optionName;
561
        }
562
        $existingOptionName = $this->findExistingOption($optionName);
563
        if (isset($existingOptionName)) {
564
            return $existingOptionName;
565
        }
566
        return $this->findOptionAmongAlternatives($optionName);
567
    }
568
569
    /**
570
     * @param string $optionName
571
     * @return string
572
     */
573
    protected function findOptionAmongAlternatives($optionName)
574
    {
575
        // Check the other direction: if the annotation contains @silent|s
576
        // and the options array has 'silent|s'.
577
        $checkMatching = explode('|', $optionName);
578
        if (count($checkMatching) > 1) {
579
            foreach ($checkMatching as $checkName) {
580
                if ($this->options->exists($checkName)) {
581
                    $this->options->rename($checkName, $optionName);
582
                    return $optionName;
583
                }
584
            }
585
        }
586
        return $optionName;
587
    }
588
589
    /**
590
     * @param string $optionName
591
     * @return string|null
592
     */
593
    protected function findExistingOption($optionName)
594
    {
595
        // Check to see if we can find the option name in an existing option,
596
        // e.g. if the options array has 'silent|s' => false, and the annotation
597
        // is @silent.
598
        foreach ($this->options()->getValues() as $name => $default) {
599
            if (in_array($optionName, explode('|', $name))) {
600
                return $name;
601
            }
602
        }
603
    }
604
605
    /**
606
     * Examine the parameters of the method for this command, and
607
     * build a list of commandline arguements for them.
608
     *
609
     * @return array
610
     */
611
    protected function determineAgumentClassifications()
612
    {
613
        $result = new DefaultsWithDescriptions();
614
        $params = $this->reflection->getParameters();
615
        $optionsFromParameters = $this->determineOptionsFromParameters();
616
        if (!empty($optionsFromParameters)) {
617
            array_pop($params);
618
        }
619
        foreach ($params as $param) {
620
            $this->addParameterToResult($result, $param);
621
        }
622
        return $result;
623
    }
624
625
    /**
626
     * Examine the provided parameter, and determine whether it
627
     * is a parameter that will be filled in with a positional
628
     * commandline argument.
629
     */
630
    protected function addParameterToResult($result, $param)
631
    {
632
        // Commandline arguments must be strings, so ignore any
633
        // parameter that is typehinted to any non-primative class.
634
        if ($param->getClass() != null) {
635
            return;
636
        }
637
        $result->add($param->name);
638
        if ($param->isDefaultValueAvailable()) {
639
            $defaultValue = $param->getDefaultValue();
640
            if (!$this->isAssoc($defaultValue)) {
641
                $result->setDefaultValue($param->name, $defaultValue);
642
            }
643
        } elseif ($param->isArray()) {
644
            $result->setDefaultValue($param->name, []);
645
        }
646
    }
647
648
    /**
649
     * Examine the parameters of the method for this command, and determine
650
     * the disposition of the options from them.
651
     *
652
     * @return array
653
     */
654
    protected function determineOptionsFromParameters()
655
    {
656
        $params = $this->reflection->getParameters();
657
        if (empty($params)) {
658
            return [];
659
        }
660
        $param = end($params);
661
        if (!$param->isDefaultValueAvailable()) {
662
            return [];
663
        }
664
        if (!$this->isAssoc($param->getDefaultValue())) {
665
            return [];
666
        }
667
        return $param->getDefaultValue();
668
    }
669
670
    /**
671
     * Helper; determine if an array is associative or not. An array
672
     * is not associative if its keys are numeric, and numbered sequentially
673
     * from zero. All other arrays are considered to be associative.
674
     *
675
     * @param array $arr The array
676
     * @return boolean
677
     */
678
    protected function isAssoc($arr)
679
    {
680
        if (!is_array($arr)) {
681
            return false;
682
        }
683
        return array_keys($arr) !== range(0, count($arr) - 1);
684
    }
685
686
    /**
687
     * Convert from a method name to the corresponding command name. A
688
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
689
     * become 'foo:bar-baz-boz'.
690
     *
691
     * @param string $camel method name.
692
     * @return string
693
     */
694
    protected function convertName($camel)
695
    {
696
        $splitter="-";
697
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
698
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
699
        return strtolower($camel);
700
    }
701
702
    /**
703
     * Parse the docBlock comment for this command, and set the
704
     * fields of this class with the data thereby obtained.
705
     */
706
    protected function parseDocBlock()
707
    {
708
        if (!$this->docBlockIsParsed) {
709
            // The parse function will insert data from the provided method
710
            // into this object, using our accessors.
711
            CommandDocBlockParserFactory::parse($this, $this->reflection);
712
            $this->docBlockIsParsed = true;
713
        }
714
    }
715
716
    /**
717
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
718
     * convert the data into the last of these forms.
719
     */
720
    protected static function convertListToCommaSeparated($text)
721
    {
722
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
723
    }
724
}
725