Completed
Pull Request — master (#85)
by Greg
02:11
created

CommandInfo::getRawAnnotations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
1
<?php
2
namespace Consolidation\AnnotatedCommand\Parser;
3
4
use Symfony\Component\Console\Input\InputOption;
5
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParser;
6
use Consolidation\AnnotatedCommand\Parser\Internal\CommandDocBlockParserFactory;
7
use Consolidation\AnnotatedCommand\AnnotationData;
8
9
/**
10
 * Given a class and method name, parse the annotations in the
11
 * DocBlock comment, and provide accessor methods for all of
12
 * the elements that are needed to create a Symfony Console Command.
13
 *
14
 * Note that the name of this class is now somewhat of a misnomer,
15
 * as we now use it to hold annotation data for hooks as well as commands.
16
 * It would probably be better to rename this to MethodInfo at some point.
17
 */
18
class CommandInfo
19
{
20
    /**
21
     * Serialization schema version. Incremented every time the serialization schema changes.
22
     */
23
    const SERIALIZATION_SCHEMA_VERSION = 2;
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 View Code Duplication
    public static function deserialize($cache)
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...
125
    {
126
        $cache = (array)$cache;
127
128
        $className = $cache['class'];
129
        $methodName = $cache['method_name'];
130
131
        return new self($className, $methodName, $cache);
132
    }
133
134
    public function cachedFileIsModified($cache)
135
    {
136
        $path = $this->reflection->getFileName();
137
        return filemtime($path) != $cache['mtime'];
138
    }
139
140
    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...
141
    {
142
        $this->otherAnnotations = new AnnotationData();
143
        // Set up a default name for the command from the method name.
144
        // This can be overridden via @command or @name annotations.
145
        $this->name = $this->convertName($methodName);
146
        $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
147
        $this->arguments = $this->determineAgumentClassifications();
148
    }
149
150
    /**
151
     * Recover the method name provided to the constructor.
152
     *
153
     * @return string
154
     */
155
    public function getMethodName()
156
    {
157
        return $this->methodName;
158
    }
159
160
    /**
161
     * Return the primary name for this command.
162
     *
163
     * @return string
164
     */
165
    public function getName()
166
    {
167
        $this->parseDocBlock();
168
        return $this->name;
169
    }
170
171
    /**
172
     * Set the primary name for this command.
173
     *
174
     * @param string $name
175
     */
176
    public function setName($name)
177
    {
178
        $this->name = $name;
179
        return $this;
180
    }
181
182
    public function getReturnType()
183
    {
184
        $this->parseDocBlock();
185
        return $this->returnType;
186
    }
187
188
    public function setReturnType($returnType)
189
    {
190
        $this->returnType = $returnType;
191
        return $this;
192
    }
193
194
    /**
195
     * Get any annotations included in the docblock comment for the
196
     * implementation method of this command that are not already
197
     * handled by the primary methods of this class.
198
     *
199
     * @return AnnotationData
200
     */
201
    public function getRawAnnotations()
202
    {
203
        $this->parseDocBlock();
204
        return $this->otherAnnotations;
205
    }
206
207
    /**
208
     * Replace the annotation data.
209
     */
210
    public function replaceRawAnnotations($annotationData)
211
    {
212
        $this->otherAnnotations = new AnnotationData((array) $annotationData);
213
        return $this;
214
    }
215
216
    /**
217
     * Get any annotations included in the docblock comment,
218
     * also including default values such as @command.  We add
219
     * in the default @command annotation late, and only in a
220
     * copy of the annotation data because we use the existance
221
     * of a @command to indicate that this CommandInfo is
222
     * a command, and not a hook or anything else.
223
     *
224
     * @return AnnotationData
225
     */
226
    public function getAnnotations()
227
    {
228
        // Also provide the path to the commandfile that these annotations
229
        // were pulled from and the classname of that file.
230
        $path = $this->reflection->getFileName();
231
        $className = $this->reflection->getDeclaringClass()->getName();
232
        return new AnnotationData(
233
            $this->getRawAnnotations()->getArrayCopy() +
234
            [
235
                'command' => $this->getName(),
236
                '_path' => $path,
237
                '_classname' => $className,
238
            ]
239
        );
240
    }
241
242
    /**
243
     * Return a specific named annotation for this command.
244
     *
245
     * @param string $annotation The name of the annotation.
246
     * @return string
247
     */
248
    public function getAnnotation($annotation)
249
    {
250
        // hasAnnotation parses the docblock
251
        if (!$this->hasAnnotation($annotation)) {
252
            return null;
253
        }
254
        return $this->otherAnnotations[$annotation];
255
    }
256
257
    /**
258
     * Check to see if the specified annotation exists for this command.
259
     *
260
     * @param string $annotation The name of the annotation.
261
     * @return boolean
262
     */
263
    public function hasAnnotation($annotation)
264
    {
265
        $this->parseDocBlock();
266
        return isset($this->otherAnnotations[$annotation]);
267
    }
268
269
    /**
270
     * Save any tag that we do not explicitly recognize in the
271
     * 'otherAnnotations' map.
272
     */
273
    public function addAnnotation($name, $content)
274
    {
275
        $this->otherAnnotations[$name] = $content;
276
    }
277
278
    /**
279
     * Remove an annotation that was previoudly set.
280
     */
281
    public function removeAnnotation($name)
282
    {
283
        unset($this->otherAnnotations[$name]);
284
    }
285
286
    /**
287
     * Get the synopsis of the command (~first line).
288
     *
289
     * @return string
290
     */
291
    public function getDescription()
292
    {
293
        $this->parseDocBlock();
294
        return $this->description;
295
    }
296
297
    /**
298
     * Set the command description.
299
     *
300
     * @param string $description The description to set.
301
     */
302
    public function setDescription($description)
303
    {
304
        $this->description = $description;
305
        return $this;
306
    }
307
308
    /**
309
     * Get the help text of the command (the description)
310
     */
311
    public function getHelp()
312
    {
313
        $this->parseDocBlock();
314
        return $this->help;
315
    }
316
    /**
317
     * Set the help text for this command.
318
     *
319
     * @param string $help The help text.
320
     */
321
    public function setHelp($help)
322
    {
323
        $this->help = $help;
324
        return $this;
325
    }
326
327
    /**
328
     * Return the list of aliases for this command.
329
     * @return string[]
330
     */
331
    public function getAliases()
332
    {
333
        $this->parseDocBlock();
334
        return $this->aliases;
335
    }
336
337
    /**
338
     * Set aliases that can be used in place of the command's primary name.
339
     *
340
     * @param string|string[] $aliases
341
     */
342
    public function setAliases($aliases)
343
    {
344
        if (is_string($aliases)) {
345
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
346
        }
347
        $this->aliases = array_filter($aliases);
348
        return $this;
349
    }
350
351
    /**
352
     * Return the examples for this command. This is @usage instead of
353
     * @example because the later is defined by the phpdoc standard to
354
     * be example method calls.
355
     *
356
     * @return string[]
357
     */
358
    public function getExampleUsages()
359
    {
360
        $this->parseDocBlock();
361
        return $this->exampleUsage;
362
    }
363
364
    /**
365
     * Add an example usage for this command.
366
     *
367
     * @param string $usage An example of the command, including the command
368
     *   name and all of its example arguments and options.
369
     * @param string $description An explanation of what the example does.
370
     */
371
    public function setExampleUsage($usage, $description)
372
    {
373
        $this->exampleUsage[$usage] = $description;
374
        return $this;
375
    }
376
377
    /**
378
     * Overwrite all example usages
379
     */
380
    public function replaceExampleUsages($usages)
381
    {
382
        $this->exampleUsage = $usages;
383
        return $this;
384
    }
385
386
    /**
387
     * Return the topics for this command.
388
     *
389
     * @return string[]
390
     */
391
    public function getTopics()
392
    {
393
        if (!$this->hasAnnotation('topics')) {
394
            return [];
395
        }
396
        $topics = $this->getAnnotation('topics');
397
        return explode(',', trim($topics));
398
    }
399
400
    /**
401
     * Return the list of refleaction parameters.
402
     *
403
     * @return ReflectionParameter[]
404
     */
405
    public function getParameters()
406
    {
407
        return $this->reflection->getParameters();
408
    }
409
410
    /**
411
     * Descriptions of commandline arguements for this command.
412
     *
413
     * @return DefaultsWithDescriptions
414
     */
415
    public function arguments()
416
    {
417
        return $this->arguments;
418
    }
419
420
    /**
421
     * Descriptions of commandline options for this command.
422
     *
423
     * @return DefaultsWithDescriptions
424
     */
425
    public function options()
426
    {
427
        return $this->options;
428
    }
429
430
    /**
431
     * Get the inputOptions for the options associated with this CommandInfo
432
     * object, e.g. via @option annotations, or from
433
     * $options = ['someoption' => 'defaultvalue'] in the command method
434
     * parameter list.
435
     *
436
     * @return InputOption[]
437
     */
438
    public function inputOptions()
439
    {
440
        if (!isset($this->inputOptions)) {
441
            $this->inputOptions = $this->createInputOptions();
442
        }
443
        return $this->inputOptions;
444
    }
445
446
    protected function createInputOptions()
447
    {
448
        $explicitOptions = [];
449
450
        $opts = $this->options()->getValues();
451
        foreach ($opts as $name => $defaultValue) {
452
            $description = $this->options()->getDescription($name);
453
454
            $fullName = $name;
455
            $shortcut = '';
456
            if (strpos($name, '|')) {
457
                list($fullName, $shortcut) = explode('|', $name, 2);
458
            }
459
460
            if (is_bool($defaultValue)) {
461
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
462
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
463
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
464
            } elseif (is_array($defaultValue)) {
465
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
466
                $explicitOptions[$fullName] = new InputOption(
467
                    $fullName,
468
                    $shortcut,
469
                    InputOption::VALUE_IS_ARRAY | $optionality,
470
                    $description,
471
                    count($defaultValue) ? $defaultValue : null
472
                );
473
            } else {
474
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
475
            }
476
        }
477
478
        return $explicitOptions;
479
    }
480
481
    /**
482
     * An option might have a name such as 'silent|s'. In this
483
     * instance, we will allow the @option or @default tag to
484
     * reference the option only by name (e.g. 'silent' or 's'
485
     * instead of 'silent|s').
486
     *
487
     * @param string $optionName
488
     * @return string
489
     */
490
    public function findMatchingOption($optionName)
491
    {
492
        // Exit fast if there's an exact match
493
        if ($this->options->exists($optionName)) {
494
            return $optionName;
495
        }
496
        $existingOptionName = $this->findExistingOption($optionName);
497
        if (isset($existingOptionName)) {
498
            return $existingOptionName;
499
        }
500
        return $this->findOptionAmongAlternatives($optionName);
501
    }
502
503
    /**
504
     * @param string $optionName
505
     * @return string
506
     */
507
    protected function findOptionAmongAlternatives($optionName)
508
    {
509
        // Check the other direction: if the annotation contains @silent|s
510
        // and the options array has 'silent|s'.
511
        $checkMatching = explode('|', $optionName);
512
        if (count($checkMatching) > 1) {
513
            foreach ($checkMatching as $checkName) {
514
                if ($this->options->exists($checkName)) {
515
                    $this->options->rename($checkName, $optionName);
516
                    return $optionName;
517
                }
518
            }
519
        }
520
        return $optionName;
521
    }
522
523
    /**
524
     * @param string $optionName
525
     * @return string|null
526
     */
527
    protected function findExistingOption($optionName)
528
    {
529
        // Check to see if we can find the option name in an existing option,
530
        // e.g. if the options array has 'silent|s' => false, and the annotation
531
        // is @silent.
532
        foreach ($this->options()->getValues() as $name => $default) {
533
            if (in_array($optionName, explode('|', $name))) {
534
                return $name;
535
            }
536
        }
537
    }
538
539
    /**
540
     * Examine the parameters of the method for this command, and
541
     * build a list of commandline arguements for them.
542
     *
543
     * @return array
544
     */
545
    protected function determineAgumentClassifications()
546
    {
547
        $result = new DefaultsWithDescriptions();
548
        $params = $this->reflection->getParameters();
549
        $optionsFromParameters = $this->determineOptionsFromParameters();
550
        if (!empty($optionsFromParameters)) {
551
            array_pop($params);
552
        }
553
        foreach ($params as $param) {
554
            $this->addParameterToResult($result, $param);
555
        }
556
        return $result;
557
    }
558
559
    /**
560
     * Examine the provided parameter, and determine whether it
561
     * is a parameter that will be filled in with a positional
562
     * commandline argument.
563
     */
564
    protected function addParameterToResult($result, $param)
565
    {
566
        // Commandline arguments must be strings, so ignore any
567
        // parameter that is typehinted to any non-primative class.
568
        if ($param->getClass() != null) {
569
            return;
570
        }
571
        $result->add($param->name);
572
        if ($param->isDefaultValueAvailable()) {
573
            $defaultValue = $param->getDefaultValue();
574
            if (!$this->isAssoc($defaultValue)) {
575
                $result->setDefaultValue($param->name, $defaultValue);
576
            }
577
        } elseif ($param->isArray()) {
578
            $result->setDefaultValue($param->name, []);
579
        }
580
    }
581
582
    /**
583
     * Examine the parameters of the method for this command, and determine
584
     * the disposition of the options from them.
585
     *
586
     * @return array
587
     */
588
    protected function determineOptionsFromParameters()
589
    {
590
        $params = $this->reflection->getParameters();
591
        if (empty($params)) {
592
            return [];
593
        }
594
        $param = end($params);
595
        if (!$param->isDefaultValueAvailable()) {
596
            return [];
597
        }
598
        if (!$this->isAssoc($param->getDefaultValue())) {
599
            return [];
600
        }
601
        return $param->getDefaultValue();
602
    }
603
604
    /**
605
     * Helper; determine if an array is associative or not. An array
606
     * is not associative if its keys are numeric, and numbered sequentially
607
     * from zero. All other arrays are considered to be associative.
608
     *
609
     * @param array $arr The array
610
     * @return boolean
611
     */
612
    protected function isAssoc($arr)
613
    {
614
        if (!is_array($arr)) {
615
            return false;
616
        }
617
        return array_keys($arr) !== range(0, count($arr) - 1);
618
    }
619
620
    /**
621
     * Convert from a method name to the corresponding command name. A
622
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
623
     * become 'foo:bar-baz-boz'.
624
     *
625
     * @param string $camel method name.
626
     * @return string
627
     */
628
    protected function convertName($camel)
629
    {
630
        $splitter="-";
631
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
632
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
633
        return strtolower($camel);
634
    }
635
636
    /**
637
     * Parse the docBlock comment for this command, and set the
638
     * fields of this class with the data thereby obtained.
639
     */
640
    protected function parseDocBlock()
641
    {
642
        if (!$this->docBlockIsParsed) {
643
            // The parse function will insert data from the provided method
644
            // into this object, using our accessors.
645
            CommandDocBlockParserFactory::parse($this, $this->reflection);
646
            $this->docBlockIsParsed = true;
647
        }
648
    }
649
650
    /**
651
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
652
     * convert the data into the last of these forms.
653
     */
654
    protected static function convertListToCommaSeparated($text)
655
    {
656
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
657
    }
658
}
659