2 namespace Consolidation\AnnotatedCommand\Parser;
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;
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.
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.
21 * Serialization schema version. Incremented every time the serialization schema changes.
23 const SERIALIZATION_SCHEMA_VERSION = 3;
26 * @var \ReflectionMethod
28 protected $reflection;
34 protected $docBlockIsParsed = false;
44 protected $description = '';
52 * @var DefaultsWithDescriptions
57 * @var DefaultsWithDescriptions
64 protected $exampleUsage = [];
69 protected $otherAnnotations;
74 protected $aliases = [];
79 protected $inputOptions;
84 protected $methodName;
89 protected $returnType;
92 * Create a new CommandInfo class for a particular method of a class.
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.
101 public function __construct($classNameOrInstance, $methodName, $cache = [])
103 $this->reflection = new \ReflectionMethod($classNameOrInstance, $methodName);
104 $this->methodName = $methodName;
105 $this->arguments = new DefaultsWithDescriptions();
106 $this->options = new DefaultsWithDescriptions();
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;
115 $this->constructFromClassAndMethod($classNameOrInstance, $methodName);
119 public static function create($classNameOrInstance, $methodName)
121 return new self($classNameOrInstance, $methodName);
124 public static function deserialize($cache)
126 $cache = (array)$cache;
127 return new self($cache['class'], $cache['method_name'], $cache);
130 public function cachedFileIsModified($cache)
132 $path = $this->reflection->getFileName();
133 return filemtime($path) != $cache['mtime'];
136 protected function constructFromClassAndMethod($classNameOrInstance, $methodName)
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();
147 * Recover the method name provided to the constructor.
151 public function getMethodName()
153 return $this->methodName;
157 * Return the primary name for this command.
161 public function getName()
163 $this->parseDocBlock();
168 * Set the primary name for this command.
170 * @param string $name
172 public function setName($name)
179 * Return whether or not this method represents a valid command
182 public function valid()
184 return !empty($this->name);
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
195 public function invalidate()
200 public function getReturnType()
202 $this->parseDocBlock();
203 return $this->returnType;
206 public function setReturnType($returnType)
208 $this->returnType = $returnType;
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.
217 * @return AnnotationData
219 public function getRawAnnotations()
221 $this->parseDocBlock();
222 return $this->otherAnnotations;
226 * Replace the annotation data.
228 public function replaceRawAnnotations($annotationData)
230 $this->otherAnnotations = new AnnotationData((array) $annotationData);
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.
242 * @return AnnotationData
244 public function getAnnotations()
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() +
253 'command' => $this->getName(),
255 '_classname' => $className,
261 * Return a specific named annotation for this command as a list.
263 * @param string $name The name of the annotation.
266 public function getAnnotationList($name)
268 // hasAnnotation parses the docblock
269 if (!$this->hasAnnotation($name)) {
272 return $this->otherAnnotations->getList($name);
277 * Return a specific named annotation for this command as a string.
279 * @param string $name The name of the annotation.
280 * @return string|null
282 public function getAnnotation($name)
284 // hasAnnotation parses the docblock
285 if (!$this->hasAnnotation($name)) {
288 return $this->otherAnnotations->get($name);
292 * Check to see if the specified annotation exists for this command.
294 * @param string $annotation The name of the annotation.
297 public function hasAnnotation($annotation)
299 $this->parseDocBlock();
300 return isset($this->otherAnnotations[$annotation]);
304 * Save any tag that we do not explicitly recognize in the
305 * 'otherAnnotations' map.
307 public function addAnnotation($name, $content)
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);
314 $this->otherAnnotations[$name] = $content;
318 * Remove an annotation that was previoudly set.
320 public function removeAnnotation($name)
322 unset($this->otherAnnotations[$name]);
326 * Get the synopsis of the command (~first line).
330 public function getDescription()
332 $this->parseDocBlock();
333 return $this->description;
337 * Set the command description.
339 * @param string $description The description to set.
341 public function setDescription($description)
343 $this->description = str_replace("\n", ' ', $description);
348 * Get the help text of the command (the description)
350 public function getHelp()
352 $this->parseDocBlock();
356 * Set the help text for this command.
358 * @param string $help The help text.
360 public function setHelp($help)
367 * Return the list of aliases for this command.
370 public function getAliases()
372 $this->parseDocBlock();
373 return $this->aliases;
377 * Set aliases that can be used in place of the command's primary name.
379 * @param string|string[] $aliases
381 public function setAliases($aliases)
383 if (is_string($aliases)) {
384 $aliases = explode(',', static::convertListToCommaSeparated($aliases));
386 $this->aliases = array_filter($aliases);
391 * Get hidden status for the command.
394 public function getHidden()
396 $this->parseDocBlock();
397 return $this->hasAnnotation('hidden');
401 * Set hidden status. List command omits hidden commands.
403 * @param bool $hidden
405 public function setHidden($hidden)
407 $this->hidden = $hidden;
412 * Return the examples for this command. This is @usage instead of
413 * @example because the later is defined by the phpdoc standard to
414 * be example method calls.
418 public function getExampleUsages()
420 $this->parseDocBlock();
421 return $this->exampleUsage;
425 * Add an example usage for this command.
427 * @param string $usage An example of the command, including the command
428 * name and all of its example arguments and options.
429 * @param string $description An explanation of what the example does.
431 public function setExampleUsage($usage, $description)
433 $this->exampleUsage[$usage] = $description;
438 * Overwrite all example usages
440 public function replaceExampleUsages($usages)
442 $this->exampleUsage = $usages;
447 * Return the topics for this command.
451 public function getTopics()
453 if (!$this->hasAnnotation('topics')) {
456 $topics = $this->getAnnotation('topics');
457 return explode(',', trim($topics));
461 * Return the list of refleaction parameters.
463 * @return ReflectionParameter[]
465 public function getParameters()
467 return $this->reflection->getParameters();
471 * Descriptions of commandline arguements for this command.
473 * @return DefaultsWithDescriptions
475 public function arguments()
477 return $this->arguments;
481 * Descriptions of commandline options for this command.
483 * @return DefaultsWithDescriptions
485 public function options()
487 return $this->options;
491 * Get the inputOptions for the options associated with this CommandInfo
492 * object, e.g. via @option annotations, or from
493 * $options = ['someoption' => 'defaultvalue'] in the command method
496 * @return InputOption[]
498 public function inputOptions()
500 if (!isset($this->inputOptions)) {
501 $this->inputOptions = $this->createInputOptions();
503 return $this->inputOptions;
506 protected function addImplicitNoOptions()
508 $opts = $this->options()->getValues();
509 foreach ($opts as $name => $defaultValue) {
510 if ($defaultValue === true) {
511 $key = 'no-' . $name;
512 if (!array_key_exists($key, $opts)) {
513 $description = "Negate --$name option.";
514 $this->options()->add($key, $description, false);
520 protected function createInputOptions()
522 $explicitOptions = [];
523 $this->addImplicitNoOptions();
525 $opts = $this->options()->getValues();
526 foreach ($opts as $name => $defaultValue) {
527 $description = $this->options()->getDescription($name);
531 if (strpos($name, '|')) {
532 list($fullName, $shortcut) = explode('|', $name, 2);
535 // Treat the following two cases identically:
536 // - 'foo' => InputOption::VALUE_OPTIONAL
538 // The first form is preferred, but we will convert the value
539 // to 'null' for storage as the option default value.
540 if ($defaultValue === InputOption::VALUE_OPTIONAL) {
541 $defaultValue = null;
544 if ($defaultValue === false) {
545 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
546 } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
547 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
548 } elseif (is_array($defaultValue)) {
549 $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
550 $explicitOptions[$fullName] = new InputOption(
553 InputOption::VALUE_IS_ARRAY | $optionality,
555 count($defaultValue) ? $defaultValue : null
558 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
562 return $explicitOptions;
566 * An option might have a name such as 'silent|s'. In this
567 * instance, we will allow the @option or @default tag to
568 * reference the option only by name (e.g. 'silent' or 's'
569 * instead of 'silent|s').
571 * @param string $optionName
574 public function findMatchingOption($optionName)
576 // Exit fast if there's an exact match
577 if ($this->options->exists($optionName)) {
580 $existingOptionName = $this->findExistingOption($optionName);
581 if (isset($existingOptionName)) {
582 return $existingOptionName;
584 return $this->findOptionAmongAlternatives($optionName);
588 * @param string $optionName
591 protected function findOptionAmongAlternatives($optionName)
593 // Check the other direction: if the annotation contains @silent|s
594 // and the options array has 'silent|s'.
595 $checkMatching = explode('|', $optionName);
596 if (count($checkMatching) > 1) {
597 foreach ($checkMatching as $checkName) {
598 if ($this->options->exists($checkName)) {
599 $this->options->rename($checkName, $optionName);
608 * @param string $optionName
609 * @return string|null
611 protected function findExistingOption($optionName)
613 // Check to see if we can find the option name in an existing option,
614 // e.g. if the options array has 'silent|s' => false, and the annotation
616 foreach ($this->options()->getValues() as $name => $default) {
617 if (in_array($optionName, explode('|', $name))) {
624 * Examine the parameters of the method for this command, and
625 * build a list of commandline arguements for them.
629 protected function determineAgumentClassifications()
631 $result = new DefaultsWithDescriptions();
632 $params = $this->reflection->getParameters();
633 $optionsFromParameters = $this->determineOptionsFromParameters();
634 if ($this->lastParameterIsOptionsArray()) {
637 foreach ($params as $param) {
638 $this->addParameterToResult($result, $param);
644 * Examine the provided parameter, and determine whether it
645 * is a parameter that will be filled in with a positional
646 * commandline argument.
648 protected function addParameterToResult($result, $param)
650 // Commandline arguments must be strings, so ignore any
651 // parameter that is typehinted to any non-primative class.
652 if ($param->getClass() != null) {
655 $result->add($param->name);
656 if ($param->isDefaultValueAvailable()) {
657 $defaultValue = $param->getDefaultValue();
658 if (!$this->isAssoc($defaultValue)) {
659 $result->setDefaultValue($param->name, $defaultValue);
661 } elseif ($param->isArray()) {
662 $result->setDefaultValue($param->name, []);
667 * Examine the parameters of the method for this command, and determine
668 * the disposition of the options from them.
672 protected function determineOptionsFromParameters()
674 $params = $this->reflection->getParameters();
675 if (empty($params)) {
678 $param = end($params);
679 if (!$param->isDefaultValueAvailable()) {
682 if (!$this->isAssoc($param->getDefaultValue())) {
685 return $param->getDefaultValue();
689 * Determine if the last argument contains $options.
691 * Two forms indicate options:
693 * - $options = ['flag' => 'default-value']
695 * Any other form, including `array $foo`, is not options.
697 protected function lastParameterIsOptionsArray()
699 $params = $this->reflection->getParameters();
700 if (empty($params)) {
703 $param = end($params);
704 if (!$param->isDefaultValueAvailable()) {
707 return is_array($param->getDefaultValue());
711 * Helper; determine if an array is associative or not. An array
712 * is not associative if its keys are numeric, and numbered sequentially
713 * from zero. All other arrays are considered to be associative.
715 * @param array $arr The array
718 protected function isAssoc($arr)
720 if (!is_array($arr)) {
723 return array_keys($arr) !== range(0, count($arr) - 1);
727 * Convert from a method name to the corresponding command name. A
728 * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
729 * become 'foo:bar-baz-boz'.
731 * @param string $camel method name.
734 protected function convertName($camel)
737 $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
738 $camel = preg_replace("/$splitter/", ':', $camel, 1);
739 return strtolower($camel);
743 * Parse the docBlock comment for this command, and set the
744 * fields of this class with the data thereby obtained.
746 protected function parseDocBlock()
748 if (!$this->docBlockIsParsed) {
749 // The parse function will insert data from the provided method
750 // into this object, using our accessors.
751 CommandDocBlockParserFactory::parse($this, $this->reflection);
752 $this->docBlockIsParsed = true;
757 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
758 * convert the data into the last of these forms.
760 protected static function convertListToCommaSeparated($text)
762 return preg_replace('#[ \t\n\r,]+#', ',', $text);