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

CommandInfo::isValidSerializedData()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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