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 * 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.
397 public function getExampleUsages()
399 $this->parseDocBlock();
400 return $this->exampleUsage;
404 * Add an example usage for this command.
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.
410 public function setExampleUsage($usage, $description)
412 $this->exampleUsage[$usage] = $description;
417 * Overwrite all example usages
419 public function replaceExampleUsages($usages)
421 $this->exampleUsage = $usages;
426 * Return the topics for this command.
430 public function getTopics()
432 if (!$this->hasAnnotation('topics')) {
435 $topics = $this->getAnnotation('topics');
436 return explode(',', trim($topics));
440 * Return the list of refleaction parameters.
442 * @return ReflectionParameter[]
444 public function getParameters()
446 return $this->reflection->getParameters();
450 * Descriptions of commandline arguements for this command.
452 * @return DefaultsWithDescriptions
454 public function arguments()
456 return $this->arguments;
460 * Descriptions of commandline options for this command.
462 * @return DefaultsWithDescriptions
464 public function options()
466 return $this->options;
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
475 * @return InputOption[]
477 public function inputOptions()
479 if (!isset($this->inputOptions)) {
480 $this->inputOptions = $this->createInputOptions();
482 return $this->inputOptions;
485 protected function createInputOptions()
487 $explicitOptions = [];
489 $opts = $this->options()->getValues();
490 foreach ($opts as $name => $defaultValue) {
491 $description = $this->options()->getDescription($name);
495 if (strpos($name, '|')) {
496 list($fullName, $shortcut) = explode('|', $name, 2);
499 if (is_bool($defaultValue)) {
500 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description);
501 } elseif ($defaultValue === InputOption::VALUE_REQUIRED) {
502 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description);
503 } elseif (is_array($defaultValue)) {
504 $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
505 $explicitOptions[$fullName] = new InputOption(
508 InputOption::VALUE_IS_ARRAY | $optionality,
510 count($defaultValue) ? $defaultValue : null
513 $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue);
517 return $explicitOptions;
521 * An option might have a name such as 'silent|s'. In this
522 * instance, we will allow the @option or @default tag to
523 * reference the option only by name (e.g. 'silent' or 's'
524 * instead of 'silent|s').
526 * @param string $optionName
529 public function findMatchingOption($optionName)
531 // Exit fast if there's an exact match
532 if ($this->options->exists($optionName)) {
535 $existingOptionName = $this->findExistingOption($optionName);
536 if (isset($existingOptionName)) {
537 return $existingOptionName;
539 return $this->findOptionAmongAlternatives($optionName);
543 * @param string $optionName
546 protected function findOptionAmongAlternatives($optionName)
548 // Check the other direction: if the annotation contains @silent|s
549 // and the options array has 'silent|s'.
550 $checkMatching = explode('|', $optionName);
551 if (count($checkMatching) > 1) {
552 foreach ($checkMatching as $checkName) {
553 if ($this->options->exists($checkName)) {
554 $this->options->rename($checkName, $optionName);
563 * @param string $optionName
564 * @return string|null
566 protected function findExistingOption($optionName)
568 // Check to see if we can find the option name in an existing option,
569 // e.g. if the options array has 'silent|s' => false, and the annotation
571 foreach ($this->options()->getValues() as $name => $default) {
572 if (in_array($optionName, explode('|', $name))) {
579 * Examine the parameters of the method for this command, and
580 * build a list of commandline arguements for them.
584 protected function determineAgumentClassifications()
586 $result = new DefaultsWithDescriptions();
587 $params = $this->reflection->getParameters();
588 $optionsFromParameters = $this->determineOptionsFromParameters();
589 if (!empty($optionsFromParameters)) {
592 foreach ($params as $param) {
593 $this->addParameterToResult($result, $param);
599 * Examine the provided parameter, and determine whether it
600 * is a parameter that will be filled in with a positional
601 * commandline argument.
603 protected function addParameterToResult($result, $param)
605 // Commandline arguments must be strings, so ignore any
606 // parameter that is typehinted to any non-primative class.
607 if ($param->getClass() != null) {
610 $result->add($param->name);
611 if ($param->isDefaultValueAvailable()) {
612 $defaultValue = $param->getDefaultValue();
613 if (!$this->isAssoc($defaultValue)) {
614 $result->setDefaultValue($param->name, $defaultValue);
616 } elseif ($param->isArray()) {
617 $result->setDefaultValue($param->name, []);
622 * Examine the parameters of the method for this command, and determine
623 * the disposition of the options from them.
627 protected function determineOptionsFromParameters()
629 $params = $this->reflection->getParameters();
630 if (empty($params)) {
633 $param = end($params);
634 if (!$param->isDefaultValueAvailable()) {
637 if (!$this->isAssoc($param->getDefaultValue())) {
640 return $param->getDefaultValue();
644 * Helper; determine if an array is associative or not. An array
645 * is not associative if its keys are numeric, and numbered sequentially
646 * from zero. All other arrays are considered to be associative.
648 * @param array $arr The array
651 protected function isAssoc($arr)
653 if (!is_array($arr)) {
656 return array_keys($arr) !== range(0, count($arr) - 1);
660 * Convert from a method name to the corresponding command name. A
661 * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will
662 * become 'foo:bar-baz-boz'.
664 * @param string $camel method name.
667 protected function convertName($camel)
670 $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel));
671 $camel = preg_replace("/$splitter/", ':', $camel, 1);
672 return strtolower($camel);
676 * Parse the docBlock comment for this command, and set the
677 * fields of this class with the data thereby obtained.
679 protected function parseDocBlock()
681 if (!$this->docBlockIsParsed) {
682 // The parse function will insert data from the provided method
683 // into this object, using our accessors.
684 CommandDocBlockParserFactory::parse($this, $this->reflection);
685 $this->docBlockIsParsed = true;
690 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
691 * convert the data into the last of these forms.
693 protected static function convertListToCommaSeparated($text)
695 return preg_replace('#[ \t\n\r,]+#', ',', $text);