Completed
Pull Request — master (#118)
by Greg
06:38
created

CommandInfo::cachedFileIsModified()   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 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.
264
     * @return array|null
265
     */
266
    public function getAnnotationList($name)
267
    {
268
        // hasAnnotation parses the docblock
269
        if (!$this->hasAnnotation($name)) {
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
            // Treat the following three cases identically:
500
            //   - 'foo' => InputOption::VALUE_OPTIONAL
501
            //   - 'foo' => true
502
            //   - 'foo' => null
503
            // The first form is preferred, but we will convert all
504
            // forms to 'null' for storage as the option default value.
505
            if (($defaultValue == InputOption::VALUE_OPTIONAL) || ($defaultValue === true)) {
506
                $defaultValue = null;
507
            }
508
509
            if (is_bool($defaultValue)) {
510
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
511
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
512
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
513
            } elseif (is_array($defaultValue)) {
514
                $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
515
                $explicitOptions[$fullName] = new InputOption(
516
                    $fullName,
517
                    $shortcut,
518
                    InputOption::VALUE_IS_ARRAY | $optionality,
519
                    $description,
520
                    count($defaultValue) ? $defaultValue : null
521
                );
522
            } else {
523
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
524
            }
525
        }
526
527
        return $explicitOptions;
528
    }
529
530
    /**
531
     * An option might have a name such as 'silent|s'. In this
532
     * instance, we will allow the @option or @default tag to
533
     * reference the option only by name (e.g. 'silent' or 's'
534
     * instead of 'silent|s').
535
     *
536
     * @param string $optionName
537
     * @return string
538
     */
539
    public function findMatchingOption($optionName)
540
    {
541
        // Exit fast if there's an exact match
542
        if ($this->options->exists($optionName)) {
543
            return $optionName;
544
        }
545
        $existingOptionName = $this->findExistingOption($optionName);
546
        if (isset($existingOptionName)) {
547
            return $existingOptionName;
548
        }
549
        return $this->findOptionAmongAlternatives($optionName);
550
    }
551
552
    /**
553
     * @param string $optionName
554
     * @return string
555
     */
556
    protected function findOptionAmongAlternatives($optionName)
557
    {
558
        // Check the other direction: if the annotation contains @silent|s
559
        // and the options array has 'silent|s'.
560
        $checkMatching = explode('|', $optionName);
561
        if (count($checkMatching) > 1) {
562
            foreach ($checkMatching as $checkName) {
563
                if ($this->options->exists($checkName)) {
564
                    $this->options->rename($checkName, $optionName);
565
                    return $optionName;
566
                }
567
            }
568
        }
569
        return $optionName;
570
    }
571
572
    /**
573
     * @param string $optionName
574
     * @return string|null
575
     */
576
    protected function findExistingOption($optionName)
577
    {
578
        // Check to see if we can find the option name in an existing option,
579
        // e.g. if the options array has 'silent|s' => false, and the annotation
580
        // is @silent.
581
        foreach ($this->options()->getValues() as $name => $default) {
582
            if (in_array($optionName, explode('|', $name))) {
583
                return $name;
584
            }
585
        }
586
    }
587
588
    /**
589
     * Examine the parameters of the method for this command, and
590
     * build a list of commandline arguements for them.
591
     *
592
     * @return array
593
     */
594
    protected function determineAgumentClassifications()
595
    {
596
        $result = new DefaultsWithDescriptions();
597
        $params = $this->reflection->getParameters();
598
        $optionsFromParameters = $this->determineOptionsFromParameters();
599
        if (!empty($optionsFromParameters)) {
600
            array_pop($params);
601
        }
602
        foreach ($params as $param) {
603
            $this->addParameterToResult($result, $param);
604
        }
605
        return $result;
606
    }
607
608
    /**
609
     * Examine the provided parameter, and determine whether it
610
     * is a parameter that will be filled in with a positional
611
     * commandline argument.
612
     */
613
    protected function addParameterToResult($result, $param)
614
    {
615
        // Commandline arguments must be strings, so ignore any
616
        // parameter that is typehinted to any non-primative class.
617
        if ($param->getClass() != null) {
618
            return;
619
        }
620
        $result->add($param->name);
621
        if ($param->isDefaultValueAvailable()) {
622
            $defaultValue = $param->getDefaultValue();
623
            if (!$this->isAssoc($defaultValue)) {
624
                $result->setDefaultValue($param->name, $defaultValue);
625
            }
626
        } elseif ($param->isArray()) {
627
            $result->setDefaultValue($param->name, []);
628
        }
629
    }
630
631
    /**
632
     * Examine the parameters of the method for this command, and determine
633
     * the disposition of the options from them.
634
     *
635
     * @return array
636
     */
637
    protected function determineOptionsFromParameters()
638
    {
639
        $params = $this->reflection->getParameters();
640
        if (empty($params)) {
641
            return [];
642
        }
643
        $param = end($params);
644
        if (!$param->isDefaultValueAvailable()) {
645
            return [];
646
        }
647
        if (!$this->isAssoc($param->getDefaultValue())) {
648
            return [];
649
        }
650
        return $param->getDefaultValue();
651
    }
652
653
    /**
654
     * Helper; determine if an array is associative or not. An array
655
     * is not associative if its keys are numeric, and numbered sequentially
656
     * from zero. All other arrays are considered to be associative.
657
     *
658
     * @param array $arr The array
659
     * @return boolean
660
     */
661
    protected function isAssoc($arr)
662
    {
663
        if (!is_array($arr)) {
664
            return false;
665
        }
666
        return array_keys($arr) !== range(0, count($arr) - 1);
667
    }
668
669
    /**
670
     * Convert from a method name to the corresponding command name. A
671
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
672
     * become 'foo:bar-baz-boz'.
673
     *
674
     * @param string $camel method name.
675
     * @return string
676
     */
677
    protected function convertName($camel)
678
    {
679
        $splitter="-";
680
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
681
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
682
        return strtolower($camel);
683
    }
684
685
    /**
686
     * Parse the docBlock comment for this command, and set the
687
     * fields of this class with the data thereby obtained.
688
     */
689
    protected function parseDocBlock()
690
    {
691
        if (!$this->docBlockIsParsed) {
692
            // The parse function will insert data from the provided method
693
            // into this object, using our accessors.
694
            CommandDocBlockParserFactory::parse($this, $this->reflection);
695
            $this->docBlockIsParsed = true;
696
        }
697
    }
698
699
    /**
700
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
701
     * convert the data into the last of these forms.
702
     */
703
    protected static function convertListToCommaSeparated($text)
704
    {
705
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
706
    }
707
}
708