Completed
Pull Request — master (#86)
by Greg
02:32
created

CommandInfo::constructFromClassAndMethod()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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