Completed
Pull Request — master (#48)
by Greg
02:24
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
     * @var \ReflectionMethod
22
     */
23
    protected $reflection;
24
25
    /**
26
     * @var boolean
27
     * @var string
28
    */
29
    protected $docBlockIsParsed;
30
31
    /**
32
     * @var string
33
     */
34
    protected $name;
35
36
    /**
37
     * @var string
38
     */
39
    protected $description = '';
40
41
    /**
42
     * @var string
43
     */
44
    protected $help = '';
45
46
    /**
47
     * @var DefaultsWithDescriptions
48
     */
49
    protected $options;
50
51
    /**
52
     * @var DefaultsWithDescriptions
53
     */
54
    protected $arguments;
55
56
    /**
57
     * @var array
58
     */
59
    protected $exampleUsage = [];
60
61
    /**
62
     * @var AnnotationData
63
     */
64
    protected $otherAnnotations;
65
66
    /**
67
     * @var array
68
     */
69
    protected $aliases = [];
70
71
    /**
72
     * @var string
73
     */
74
    protected $methodName;
75
76
    /**
77
     * @var string
78
     */
79
    protected $returnType;
80
81
    /**
82
     * @var string
83
     */
84
    protected $optionParamName;
85
86
    /**
87
     * Create a new CommandInfo class for a particular method of a class.
88
     *
89
     * @param string|mixed $classNameOrInstance The name of a class, or an
90
     *   instance of it.
91
     * @param string $methodName The name of the method to get info about.
92
     */
93
    public function __construct($classNameOrInstance, $methodName)
94
    {
95
        $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
96
        $this->methodName = $methodName;
97
        $this->otherAnnotations = new AnnotationData();
98
        // Set up a default name for the command from the method name.
99
        // This can be overridden via @command or @name annotations.
100
        $this->name = $this->convertName($this->reflection->name);
101
        $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false);
102
        $this->arguments = $this->determineAgumentClassifications();
103
        // Remember the name of the last parameter, if it holds the options.
104
        // We will use this information to ignore @param annotations for the options.
105
        if (!empty($this->options)) {
106
            $this->optionParamName = $this->lastParameterName();
107
        }
108
    }
109
110
    /**
111
     * Recover the method name provided to the constructor.
112
     *
113
     * @return string
114
     */
115
    public function getMethodName()
116
    {
117
        return $this->methodName;
118
    }
119
120
    /**
121
     * Return the primary name for this command.
122
     *
123
     * @return string
124
     */
125
    public function getName()
126
    {
127
        $this->parseDocBlock();
128
        return $this->name;
129
    }
130
131
    /**
132
     * Set the primary name for this command.
133
     *
134
     * @param string $name
135
     */
136
    public function setName($name)
137
    {
138
        $this->name = $name;
139
    }
140
141
    public function getReturnType()
142
    {
143
        $this->parseDocBlock();
144
        return $this->returnType;
145
    }
146
147
    public function setReturnType($returnType)
148
    {
149
        $this->returnType = $returnType;
150
    }
151
152
    /**
153
     * Get any annotations included in the docblock comment for the
154
     * implementation method of this command that are not already
155
     * handled by the primary methods of this class.
156
     *
157
     * @return AnnotationData
158
     */
159
    public function getRawAnnotations()
160
    {
161
        $this->parseDocBlock();
162
        return $this->otherAnnotations;
163
    }
164
165
    /**
166
     * Get any annotations included in the docblock comment,
167
     * also including default values such as @command.  We add
168
     * in the default @command annotation late, and only in a
169
     * copy of the annotation data because we use the existance
170
     * of a @command to indicate that this CommandInfo is
171
     * a command, and not a hook or anything else.
172
     *
173
     * @return AnnotationData
174
     */
175
    public function getAnnotations()
176
    {
177
        return new AnnotationData(
178
            $this->getRawAnnotations()->getArrayCopy() +
179
            [
180
                'command' => $this->getName(),
181
            ]
182
        );
183
    }
184
185
    /**
186
     * Return a specific named annotation for this command.
187
     *
188
     * @param string $annotation The name of the annotation.
189
     * @return string
190
     */
191
    public function getAnnotation($annotation)
192
    {
193
        // hasAnnotation parses the docblock
194
        if (!$this->hasAnnotation($annotation)) {
195
            return null;
196
        }
197
        return $this->otherAnnotations[$annotation];
198
    }
199
200
    /**
201
     * Check to see if the specified annotation exists for this command.
202
     *
203
     * @param string $annotation The name of the annotation.
204
     * @return boolean
205
     */
206
    public function hasAnnotation($annotation)
207
    {
208
        $this->parseDocBlock();
209
        return isset($this->otherAnnotations[$annotation]);
210
    }
211
212
    /**
213
     * Save any tag that we do not explicitly recognize in the
214
     * 'otherAnnotations' map.
215
     */
216
    public function addAnnotation($name, $content)
217
    {
218
        $this->otherAnnotations[$name] = $content;
219
    }
220
221
    /**
222
     * Remove an annotation that was previoudly set.
223
     */
224
    public function removeAnnotation($name)
225
    {
226
        unset($this->otherAnnotations[$name]);
227
    }
228
229
    /**
230
     * Get the synopsis of the command (~first line).
231
     *
232
     * @return string
233
     */
234
    public function getDescription()
235
    {
236
        $this->parseDocBlock();
237
        return $this->description;
238
    }
239
240
    /**
241
     * Set the command description.
242
     *
243
     * @param string $description The description to set.
244
     */
245
    public function setDescription($description)
246
    {
247
        $this->description = $description;
248
    }
249
250
    /**
251
     * Get the help text of the command (the description)
252
     */
253
    public function getHelp()
254
    {
255
        $this->parseDocBlock();
256
        return $this->help;
257
    }
258
    /**
259
     * Set the help text for this command.
260
     *
261
     * @param string $help The help text.
262
     */
263
    public function setHelp($help)
264
    {
265
        $this->help = $help;
266
    }
267
268
    /**
269
     * Return the list of aliases for this command.
270
     * @return string[]
271
     */
272
    public function getAliases()
273
    {
274
        $this->parseDocBlock();
275
        return $this->aliases;
276
    }
277
278
    /**
279
     * Set aliases that can be used in place of the command's primary name.
280
     *
281
     * @param string|string[] $aliases
282
     */
283
    public function setAliases($aliases)
284
    {
285
        if (is_string($aliases)) {
286
            $aliases = explode(',', static::convertListToCommaSeparated($aliases));
287
        }
288
        $this->aliases = array_filter($aliases);
289
    }
290
291
    /**
292
     * Return the examples for this command. This is @usage instead of
293
     * @example because the later is defined by the phpdoc standard to
294
     * be example method calls.
295
     *
296
     * @return string[]
297
     */
298
    public function getExampleUsages()
299
    {
300
        $this->parseDocBlock();
301
        return $this->exampleUsage;
302
    }
303
304
    /**
305
     * Add an example usage for this command.
306
     *
307
     * @param string $usage An example of the command, including the command
308
     *   name and all of its example arguments and options.
309
     * @param string $description An explanation of what the example does.
310
     */
311
    public function setExampleUsage($usage, $description)
312
    {
313
        $this->exampleUsage[$usage] = $description;
314
    }
315
316
    /**
317
     * Return the list of refleaction parameters.
318
     *
319
     * @return ReflectionParameter[]
320
     */
321
    public function getParameters()
322
    {
323
        return $this->reflection->getParameters();
324
    }
325
326
    /**
327
     * Descriptions of commandline arguements for this command.
328
     *
329
     * @return DefaultsWithDescriptions
330
     */
331
    public function arguments()
332
    {
333
        return $this->arguments;
334
    }
335
336
    /**
337
     * Descriptions of commandline options for this command.
338
     *
339
     * @return DefaultsWithDescriptions
340
     */
341
    public function options()
342
    {
343
        return $this->options;
344
    }
345
346
    /**
347
     * Return the name of the last parameter if it holds the options.
348
     */
349
    public function optionParamName()
350
    {
351
        return $this->optionParamName;
352
    }
353
354
    /**
355
     * Get the inputOptions for the options associated with this CommandInfo
356
     * object, e.g. via @option annotations, or from
357
     * $options = ['someoption' => 'defaultvalue'] in the command method
358
     * parameter list.
359
     *
360
     * @return InputOption[]
361
     */
362
    public function inputOptions()
363
    {
364
        $explicitOptions = [];
365
366
        $opts = $this->options()->getValues();
367
        foreach ($opts as $name => $defaultValue) {
368
            $description = $this->options()->getDescription($name);
369
370
            $fullName = $name;
371
            $shortcut = '';
372
            if (strpos($name, '|')) {
373
                list($fullName, $shortcut) = explode('|', $name, 2);
374
            }
375
376
            if (is_bool($defaultValue)) {
377
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
378
            } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
379
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
380
            } else {
381
                $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
382
            }
383
        }
384
385
        return $explicitOptions;
386
    }
387
388
    /**
389
     * An option might have a name such as 'silent|s'. In this
390
     * instance, we will allow the @option or @default tag to
391
     * reference the option only by name (e.g. 'silent' or 's'
392
     * instead of 'silent|s').
393
     *
394
     * @param string $optionName
395
     * @return string
396
     */
397
    public function findMatchingOption($optionName)
398
    {
399
        // Exit fast if there's an exact match
400
        if ($this->options->exists($optionName)) {
401
            return $optionName;
402
        }
403
        $existingOptionName = $this->findExistingOption($optionName);
404
        if (isset($existingOptionName)) {
405
            return $existingOptionName;
406
        }
407
        return $this->findOptionAmongAlternatives($optionName);
408
    }
409
410
    /**
411
     * @param string $optionName
412
     * @return string
413
     */
414
    protected function findOptionAmongAlternatives($optionName)
415
    {
416
        // Check the other direction: if the annotation contains @silent|s
417
        // and the options array has 'silent|s'.
418
        $checkMatching = explode('|', $optionName);
419
        if (count($checkMatching) > 1) {
420
            foreach ($checkMatching as $checkName) {
421
                if ($this->options->exists($checkName)) {
422
                    $this->options->rename($checkName, $optionName);
423
                    return $optionName;
424
                }
425
            }
426
        }
427
        return $optionName;
428
    }
429
430
    /**
431
     * @param string $optionName
432
     * @return string|null
433
     */
434
    protected function findExistingOption($optionName)
435
    {
436
        // Check to see if we can find the option name in an existing option,
437
        // e.g. if the options array has 'silent|s' => false, and the annotation
438
        // is @silent.
439
        foreach ($this->options()->getValues() as $name => $default) {
440
            if (in_array($optionName, explode('|', $name))) {
441
                return $name;
442
            }
443
        }
444
    }
445
446
    /**
447
     * Examine the parameters of the method for this command, and
448
     * build a list of commandline arguements for them.
449
     *
450
     * @return array
451
     */
452
    protected function determineAgumentClassifications()
453
    {
454
        $result = new DefaultsWithDescriptions();
455
        $params = $this->reflection->getParameters();
456
        $optionsFromParameters = $this->determineOptionsFromParameters();
457
        if (!empty($optionsFromParameters)) {
458
            array_pop($params);
459
        }
460
        foreach ($params as $param) {
461
            $this->addParameterToResult($result, $param);
462
        }
463
        return $result;
464
    }
465
466
    /**
467
     * Examine the provided parameter, and determine whether it
468
     * is a parameter that will be filled in with a positional
469
     * commandline argument.
470
     */
471
    protected function addParameterToResult($result, $param)
472
    {
473
        // Commandline arguments must be strings, so ignore any
474
        // parameter that is typehinted to any non-primative class.
475
        if ($param->getClass() != null) {
476
            return;
477
        }
478
        $result->add($param->name);
479
        if ($param->isDefaultValueAvailable()) {
480
            $defaultValue = $param->getDefaultValue();
481
            if (!$this->isAssoc($defaultValue)) {
482
                $result->setDefaultValue($param->name, $defaultValue);
483
            }
484
        } elseif ($param->isArray()) {
485
            $result->setDefaultValue($param->name, []);
486
        }
487
    }
488
489
    /**
490
     * Examine the parameters of the method for this command, and determine
491
     * the disposition of the options from them.
492
     *
493
     * @return array
494
     */
495
    protected function determineOptionsFromParameters()
496
    {
497
        $params = $this->reflection->getParameters();
498
        if (empty($params)) {
499
            return [];
500
        }
501
        $param = end($params);
502
        if (!$param->isDefaultValueAvailable()) {
503
            return [];
504
        }
505
        if (!$this->isAssoc($param->getDefaultValue())) {
506
            return [];
507
        }
508
        return $param->getDefaultValue();
509
    }
510
511
    protected function lastParameterName()
512
    {
513
        $params = $this->reflection->getParameters();
514
        $param = end($params);
515
        if (!$param) {
516
            return '';
517
        }
518
        return $param->name;
519
    }
520
521
    /**
522
     * Helper; determine if an array is associative or not. An array
523
     * is not associative if its keys are numeric, and numbered sequentially
524
     * from zero. All other arrays are considered to be associative.
525
     *
526
     * @param arrau $arr The array
527
     * @return boolean
528
     */
529
    protected function isAssoc($arr)
530
    {
531
        if (!is_array($arr)) {
532
            return false;
533
        }
534
        return array_keys($arr) !== range(0, count($arr) - 1);
535
    }
536
537
    /**
538
     * Convert from a method name to the corresponding command name. A
539
     * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
540
     * become 'foo:bar-baz-boz'.
541
     *
542
     * @param string $camel method name.
543
     * @return string
544
     */
545
    protected function convertName($camel)
546
    {
547
        $splitter="-";
548
        $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
549
        $camel = preg_replace("/$splitter/", ':', $camel, 1);
550
        return strtolower($camel);
551
    }
552
553
    /**
554
     * Parse the docBlock comment for this command, and set the
555
     * fields of this class with the data thereby obtained.
556
     */
557
    protected function parseDocBlock()
558
    {
559
        if (!$this->docBlockIsParsed) {
560
            // The parse function will insert data from the provided method
561
            // into this object, using our accessors.
562
            CommandDocBlockParserFactory::parse($this, $this->reflection);
563
            $this->docBlockIsParsed = true;
564
        }
565
    }
566
567
    /**
568
     * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
569
     * convert the data into the last of these forms.
570
     */
571
    protected static function convertListToCommaSeparated($text)
572
    {
573
        return preg_replace('#[ \t\n\r,]+#', ',', $text);
574
    }
575
}
576