Chris@0: reflection = new \ReflectionMethod($classNameOrInstance, $methodName); Chris@0: $this->methodName = $methodName; Chris@0: $this->arguments = new DefaultsWithDescriptions(); Chris@0: $this->options = new DefaultsWithDescriptions(); Chris@0: Chris@0: // If the cache came from a newer version, ignore it and Chris@0: // regenerate the cached information. Chris@0: if (!empty($cache) && CommandInfoDeserializer::isValidSerializedData($cache) && !$this->cachedFileIsModified($cache)) { Chris@0: $deserializer = new CommandInfoDeserializer(); Chris@0: $deserializer->constructFromCache($this, $cache); Chris@0: $this->docBlockIsParsed = true; Chris@0: } else { Chris@0: $this->constructFromClassAndMethod($classNameOrInstance, $methodName); Chris@0: } Chris@0: } Chris@0: Chris@0: public static function create($classNameOrInstance, $methodName) Chris@0: { Chris@0: return new self($classNameOrInstance, $methodName); Chris@0: } Chris@0: Chris@0: public static function deserialize($cache) Chris@0: { Chris@0: $cache = (array)$cache; Chris@0: return new self($cache['class'], $cache['method_name'], $cache); Chris@0: } Chris@0: Chris@0: public function cachedFileIsModified($cache) Chris@0: { Chris@0: $path = $this->reflection->getFileName(); Chris@0: return filemtime($path) != $cache['mtime']; Chris@0: } Chris@0: Chris@0: protected function constructFromClassAndMethod($classNameOrInstance, $methodName) Chris@0: { Chris@0: $this->otherAnnotations = new AnnotationData(); Chris@0: // Set up a default name for the command from the method name. Chris@0: // This can be overridden via @command or @name annotations. Chris@0: $this->name = $this->convertName($methodName); Chris@0: $this->options = new DefaultsWithDescriptions($this->determineOptionsFromParameters(), false); Chris@0: $this->arguments = $this->determineAgumentClassifications(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Recover the method name provided to the constructor. Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public function getMethodName() Chris@0: { Chris@0: return $this->methodName; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the primary name for this command. Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public function getName() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->name; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Set the primary name for this command. Chris@0: * Chris@0: * @param string $name Chris@0: */ Chris@0: public function setName($name) Chris@0: { Chris@0: $this->name = $name; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return whether or not this method represents a valid command Chris@0: * or hook. Chris@0: */ Chris@0: public function valid() Chris@0: { Chris@0: return !empty($this->name); Chris@0: } Chris@0: Chris@0: /** Chris@0: * If higher-level code decides that this CommandInfo is not interesting Chris@0: * or useful (if it is not a command method or a hook method), then Chris@0: * we will mark it as invalid to prevent it from being created as a command. Chris@0: * We still cache a placeholder record for invalid methods, so that we Chris@0: * do not need to re-parse the method again later simply to determine that Chris@0: * it is invalid. Chris@0: */ Chris@0: public function invalidate() Chris@0: { Chris@0: $this->name = ''; Chris@0: } Chris@0: Chris@0: public function getReturnType() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->returnType; Chris@0: } Chris@0: Chris@17: public function getInjectedClasses() Chris@17: { Chris@17: $this->parseDocBlock(); Chris@17: return $this->injectedClasses; Chris@17: } Chris@17: Chris@17: public function setInjectedClasses($injectedClasses) Chris@17: { Chris@17: $this->injectedClasses = $injectedClasses; Chris@17: return $this; Chris@17: } Chris@17: Chris@0: public function setReturnType($returnType) Chris@0: { Chris@0: $this->returnType = $returnType; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get any annotations included in the docblock comment for the Chris@0: * implementation method of this command that are not already Chris@0: * handled by the primary methods of this class. Chris@0: * Chris@0: * @return AnnotationData Chris@0: */ Chris@0: public function getRawAnnotations() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->otherAnnotations; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Replace the annotation data. Chris@0: */ Chris@0: public function replaceRawAnnotations($annotationData) Chris@0: { Chris@0: $this->otherAnnotations = new AnnotationData((array) $annotationData); Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get any annotations included in the docblock comment, Chris@0: * also including default values such as @command. We add Chris@0: * in the default @command annotation late, and only in a Chris@0: * copy of the annotation data because we use the existance Chris@0: * of a @command to indicate that this CommandInfo is Chris@0: * a command, and not a hook or anything else. Chris@0: * Chris@0: * @return AnnotationData Chris@0: */ Chris@0: public function getAnnotations() Chris@0: { Chris@0: // Also provide the path to the commandfile that these annotations Chris@0: // were pulled from and the classname of that file. Chris@0: $path = $this->reflection->getFileName(); Chris@0: $className = $this->reflection->getDeclaringClass()->getName(); Chris@0: return new AnnotationData( Chris@0: $this->getRawAnnotations()->getArrayCopy() + Chris@0: [ Chris@0: 'command' => $this->getName(), Chris@0: '_path' => $path, Chris@0: '_classname' => $className, Chris@0: ] Chris@0: ); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return a specific named annotation for this command as a list. Chris@0: * Chris@0: * @param string $name The name of the annotation. Chris@0: * @return array|null Chris@0: */ Chris@0: public function getAnnotationList($name) Chris@0: { Chris@0: // hasAnnotation parses the docblock Chris@0: if (!$this->hasAnnotation($name)) { Chris@0: return null; Chris@0: } Chris@0: return $this->otherAnnotations->getList($name); Chris@0: ; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return a specific named annotation for this command as a string. Chris@0: * Chris@0: * @param string $name The name of the annotation. Chris@0: * @return string|null Chris@0: */ Chris@0: public function getAnnotation($name) Chris@0: { Chris@0: // hasAnnotation parses the docblock Chris@0: if (!$this->hasAnnotation($name)) { Chris@0: return null; Chris@0: } Chris@0: return $this->otherAnnotations->get($name); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Check to see if the specified annotation exists for this command. Chris@0: * Chris@0: * @param string $annotation The name of the annotation. Chris@0: * @return boolean Chris@0: */ Chris@0: public function hasAnnotation($annotation) Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return isset($this->otherAnnotations[$annotation]); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Save any tag that we do not explicitly recognize in the Chris@0: * 'otherAnnotations' map. Chris@0: */ Chris@0: public function addAnnotation($name, $content) Chris@0: { Chris@0: // Convert to an array and merge if there are multiple Chris@0: // instances of the same annotation defined. Chris@0: if (isset($this->otherAnnotations[$name])) { Chris@0: $content = array_merge((array) $this->otherAnnotations[$name], (array)$content); Chris@0: } Chris@0: $this->otherAnnotations[$name] = $content; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Remove an annotation that was previoudly set. Chris@0: */ Chris@0: public function removeAnnotation($name) Chris@0: { Chris@0: unset($this->otherAnnotations[$name]); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get the synopsis of the command (~first line). Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: public function getDescription() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->description; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Set the command description. Chris@0: * Chris@0: * @param string $description The description to set. Chris@0: */ Chris@0: public function setDescription($description) Chris@0: { Chris@0: $this->description = str_replace("\n", ' ', $description); Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get the help text of the command (the description) Chris@0: */ Chris@0: public function getHelp() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->help; Chris@0: } Chris@0: /** Chris@0: * Set the help text for this command. Chris@0: * Chris@0: * @param string $help The help text. Chris@0: */ Chris@0: public function setHelp($help) Chris@0: { Chris@0: $this->help = $help; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the list of aliases for this command. Chris@0: * @return string[] Chris@0: */ Chris@0: public function getAliases() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->aliases; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Set aliases that can be used in place of the command's primary name. Chris@0: * Chris@0: * @param string|string[] $aliases Chris@0: */ Chris@0: public function setAliases($aliases) Chris@0: { Chris@0: if (is_string($aliases)) { Chris@0: $aliases = explode(',', static::convertListToCommaSeparated($aliases)); Chris@0: } Chris@0: $this->aliases = array_filter($aliases); Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get hidden status for the command. Chris@0: * @return bool Chris@0: */ Chris@0: public function getHidden() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->hasAnnotation('hidden'); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Set hidden status. List command omits hidden commands. Chris@0: * Chris@0: * @param bool $hidden Chris@0: */ Chris@0: public function setHidden($hidden) Chris@0: { Chris@0: $this->hidden = $hidden; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the examples for this command. This is @usage instead of Chris@0: * @example because the later is defined by the phpdoc standard to Chris@0: * be example method calls. Chris@0: * Chris@0: * @return string[] Chris@0: */ Chris@0: public function getExampleUsages() Chris@0: { Chris@0: $this->parseDocBlock(); Chris@0: return $this->exampleUsage; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Add an example usage for this command. Chris@0: * Chris@0: * @param string $usage An example of the command, including the command Chris@0: * name and all of its example arguments and options. Chris@0: * @param string $description An explanation of what the example does. Chris@0: */ Chris@0: public function setExampleUsage($usage, $description) Chris@0: { Chris@0: $this->exampleUsage[$usage] = $description; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Overwrite all example usages Chris@0: */ Chris@0: public function replaceExampleUsages($usages) Chris@0: { Chris@0: $this->exampleUsage = $usages; Chris@0: return $this; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the topics for this command. Chris@0: * Chris@0: * @return string[] Chris@0: */ Chris@0: public function getTopics() Chris@0: { Chris@0: if (!$this->hasAnnotation('topics')) { Chris@0: return []; Chris@0: } Chris@0: $topics = $this->getAnnotation('topics'); Chris@0: return explode(',', trim($topics)); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the list of refleaction parameters. Chris@0: * Chris@0: * @return ReflectionParameter[] Chris@0: */ Chris@0: public function getParameters() Chris@0: { Chris@0: return $this->reflection->getParameters(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Descriptions of commandline arguements for this command. Chris@0: * Chris@0: * @return DefaultsWithDescriptions Chris@0: */ Chris@0: public function arguments() Chris@0: { Chris@0: return $this->arguments; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Descriptions of commandline options for this command. Chris@0: * Chris@0: * @return DefaultsWithDescriptions Chris@0: */ Chris@0: public function options() Chris@0: { Chris@0: return $this->options; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Get the inputOptions for the options associated with this CommandInfo Chris@0: * object, e.g. via @option annotations, or from Chris@0: * $options = ['someoption' => 'defaultvalue'] in the command method Chris@0: * parameter list. Chris@0: * Chris@0: * @return InputOption[] Chris@0: */ Chris@0: public function inputOptions() Chris@0: { Chris@0: if (!isset($this->inputOptions)) { Chris@0: $this->inputOptions = $this->createInputOptions(); Chris@0: } Chris@0: return $this->inputOptions; Chris@0: } Chris@0: Chris@0: protected function addImplicitNoOptions() Chris@0: { Chris@0: $opts = $this->options()->getValues(); Chris@0: foreach ($opts as $name => $defaultValue) { Chris@0: if ($defaultValue === true) { Chris@0: $key = 'no-' . $name; Chris@0: if (!array_key_exists($key, $opts)) { Chris@0: $description = "Negate --$name option."; Chris@0: $this->options()->add($key, $description, false); Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: protected function createInputOptions() Chris@0: { Chris@0: $explicitOptions = []; Chris@0: $this->addImplicitNoOptions(); Chris@0: Chris@0: $opts = $this->options()->getValues(); Chris@0: foreach ($opts as $name => $defaultValue) { Chris@0: $description = $this->options()->getDescription($name); Chris@0: Chris@0: $fullName = $name; Chris@0: $shortcut = ''; Chris@0: if (strpos($name, '|')) { Chris@0: list($fullName, $shortcut) = explode('|', $name, 2); Chris@0: } Chris@0: Chris@0: // Treat the following two cases identically: Chris@0: // - 'foo' => InputOption::VALUE_OPTIONAL Chris@0: // - 'foo' => null Chris@0: // The first form is preferred, but we will convert the value Chris@0: // to 'null' for storage as the option default value. Chris@0: if ($defaultValue === InputOption::VALUE_OPTIONAL) { Chris@0: $defaultValue = null; Chris@0: } Chris@0: Chris@0: if ($defaultValue === false) { Chris@0: $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_NONE, $description); Chris@0: } elseif ($defaultValue === InputOption::VALUE_REQUIRED) { Chris@0: $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_REQUIRED, $description); Chris@0: } elseif (is_array($defaultValue)) { Chris@0: $optionality = count($defaultValue) ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; Chris@0: $explicitOptions[$fullName] = new InputOption( Chris@0: $fullName, Chris@0: $shortcut, Chris@0: InputOption::VALUE_IS_ARRAY | $optionality, Chris@0: $description, Chris@0: count($defaultValue) ? $defaultValue : null Chris@0: ); Chris@0: } else { Chris@0: $explicitOptions[$fullName] = new InputOption($fullName, $shortcut, InputOption::VALUE_OPTIONAL, $description, $defaultValue); Chris@0: } Chris@0: } Chris@0: Chris@0: return $explicitOptions; Chris@0: } Chris@0: Chris@0: /** Chris@0: * An option might have a name such as 'silent|s'. In this Chris@0: * instance, we will allow the @option or @default tag to Chris@0: * reference the option only by name (e.g. 'silent' or 's' Chris@0: * instead of 'silent|s'). Chris@0: * Chris@0: * @param string $optionName Chris@0: * @return string Chris@0: */ Chris@0: public function findMatchingOption($optionName) Chris@0: { Chris@0: // Exit fast if there's an exact match Chris@0: if ($this->options->exists($optionName)) { Chris@0: return $optionName; Chris@0: } Chris@0: $existingOptionName = $this->findExistingOption($optionName); Chris@0: if (isset($existingOptionName)) { Chris@0: return $existingOptionName; Chris@0: } Chris@0: return $this->findOptionAmongAlternatives($optionName); Chris@0: } Chris@0: Chris@0: /** Chris@0: * @param string $optionName Chris@0: * @return string Chris@0: */ Chris@0: protected function findOptionAmongAlternatives($optionName) Chris@0: { Chris@0: // Check the other direction: if the annotation contains @silent|s Chris@0: // and the options array has 'silent|s'. Chris@0: $checkMatching = explode('|', $optionName); Chris@0: if (count($checkMatching) > 1) { Chris@0: foreach ($checkMatching as $checkName) { Chris@0: if ($this->options->exists($checkName)) { Chris@0: $this->options->rename($checkName, $optionName); Chris@0: return $optionName; Chris@0: } Chris@0: } Chris@0: } Chris@0: return $optionName; Chris@0: } Chris@0: Chris@0: /** Chris@0: * @param string $optionName Chris@0: * @return string|null Chris@0: */ Chris@0: protected function findExistingOption($optionName) Chris@0: { Chris@0: // Check to see if we can find the option name in an existing option, Chris@0: // e.g. if the options array has 'silent|s' => false, and the annotation Chris@0: // is @silent. Chris@0: foreach ($this->options()->getValues() as $name => $default) { Chris@0: if (in_array($optionName, explode('|', $name))) { Chris@0: return $name; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Examine the parameters of the method for this command, and Chris@0: * build a list of commandline arguements for them. Chris@0: * Chris@0: * @return array Chris@0: */ Chris@0: protected function determineAgumentClassifications() Chris@0: { Chris@0: $result = new DefaultsWithDescriptions(); Chris@0: $params = $this->reflection->getParameters(); Chris@0: $optionsFromParameters = $this->determineOptionsFromParameters(); Chris@0: if ($this->lastParameterIsOptionsArray()) { Chris@0: array_pop($params); Chris@0: } Chris@17: while (!empty($params) && ($params[0]->getClass() != null)) { Chris@17: $param = array_shift($params); Chris@17: $injectedClass = $param->getClass()->getName(); Chris@17: array_unshift($this->injectedClasses, $injectedClass); Chris@17: } Chris@0: foreach ($params as $param) { Chris@0: $this->addParameterToResult($result, $param); Chris@0: } Chris@0: return $result; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Examine the provided parameter, and determine whether it Chris@0: * is a parameter that will be filled in with a positional Chris@0: * commandline argument. Chris@0: */ Chris@0: protected function addParameterToResult($result, $param) Chris@0: { Chris@0: // Commandline arguments must be strings, so ignore any Chris@0: // parameter that is typehinted to any non-primative class. Chris@0: if ($param->getClass() != null) { Chris@0: return; Chris@0: } Chris@0: $result->add($param->name); Chris@0: if ($param->isDefaultValueAvailable()) { Chris@0: $defaultValue = $param->getDefaultValue(); Chris@0: if (!$this->isAssoc($defaultValue)) { Chris@0: $result->setDefaultValue($param->name, $defaultValue); Chris@0: } Chris@0: } elseif ($param->isArray()) { Chris@0: $result->setDefaultValue($param->name, []); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Examine the parameters of the method for this command, and determine Chris@0: * the disposition of the options from them. Chris@0: * Chris@0: * @return array Chris@0: */ Chris@0: protected function determineOptionsFromParameters() Chris@0: { Chris@0: $params = $this->reflection->getParameters(); Chris@0: if (empty($params)) { Chris@0: return []; Chris@0: } Chris@0: $param = end($params); Chris@0: if (!$param->isDefaultValueAvailable()) { Chris@0: return []; Chris@0: } Chris@0: if (!$this->isAssoc($param->getDefaultValue())) { Chris@0: return []; Chris@0: } Chris@0: return $param->getDefaultValue(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Determine if the last argument contains $options. Chris@0: * Chris@0: * Two forms indicate options: Chris@0: * - $options = [] Chris@0: * - $options = ['flag' => 'default-value'] Chris@0: * Chris@0: * Any other form, including `array $foo`, is not options. Chris@0: */ Chris@0: protected function lastParameterIsOptionsArray() Chris@0: { Chris@0: $params = $this->reflection->getParameters(); Chris@0: if (empty($params)) { Chris@0: return []; Chris@0: } Chris@0: $param = end($params); Chris@0: if (!$param->isDefaultValueAvailable()) { Chris@0: return []; Chris@0: } Chris@0: return is_array($param->getDefaultValue()); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Helper; determine if an array is associative or not. An array Chris@0: * is not associative if its keys are numeric, and numbered sequentially Chris@0: * from zero. All other arrays are considered to be associative. Chris@0: * Chris@0: * @param array $arr The array Chris@0: * @return boolean Chris@0: */ Chris@0: protected function isAssoc($arr) Chris@0: { Chris@0: if (!is_array($arr)) { Chris@0: return false; Chris@0: } Chris@0: return array_keys($arr) !== range(0, count($arr) - 1); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Convert from a method name to the corresponding command name. A Chris@0: * method 'fooBar' will become 'foo:bar', and 'fooBarBazBoz' will Chris@0: * become 'foo:bar-baz-boz'. Chris@0: * Chris@0: * @param string $camel method name. Chris@0: * @return string Chris@0: */ Chris@0: protected function convertName($camel) Chris@0: { Chris@0: $splitter="-"; Chris@0: $camel=preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '$0', preg_replace('/(?!^)[[:upper:]]+/', $splitter.'$0', $camel)); Chris@0: $camel = preg_replace("/$splitter/", ':', $camel, 1); Chris@0: return strtolower($camel); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parse the docBlock comment for this command, and set the Chris@0: * fields of this class with the data thereby obtained. Chris@0: */ Chris@0: protected function parseDocBlock() Chris@0: { Chris@0: if (!$this->docBlockIsParsed) { Chris@0: // The parse function will insert data from the provided method Chris@0: // into this object, using our accessors. Chris@0: CommandDocBlockParserFactory::parse($this, $this->reflection); Chris@0: $this->docBlockIsParsed = true; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c', Chris@0: * convert the data into the last of these forms. Chris@0: */ Chris@0: protected static function convertListToCommaSeparated($text) Chris@0: { Chris@0: return preg_replace('#[ \t\n\r,]+#', ',', $text); Chris@0: } Chris@0: }