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

CommandInfo::removeAnnotation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
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.
0 ignored issues
show
Bug introduced by
There is no parameter named $name. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
264
     * @return array|null
265
     */
266
    public function getAnnotationList($annotation)
0 ignored issues
show
Unused Code introduced by
The parameter $annotation 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...
267
    {
268
        // hasAnnotation parses the docblock
269
        if (!$this->hasAnnotation($name)) {
0 ignored issues
show
Bug introduced by
The variable $name does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
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