annotate vendor/consolidation/annotated-command/src/Parser/Internal/BespokeDocBlockParser.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents af1871eacc83
children
rev   line source
Chris@0 1 <?php
Chris@0 2 namespace Consolidation\AnnotatedCommand\Parser\Internal;
Chris@0 3
Chris@0 4 use Consolidation\AnnotatedCommand\Parser\CommandInfo;
Chris@0 5 use Consolidation\AnnotatedCommand\Parser\DefaultsWithDescriptions;
Chris@0 6
Chris@0 7 /**
Chris@0 8 * Given a class and method name, parse the annotations in the
Chris@0 9 * DocBlock comment, and provide accessor methods for all of
Chris@0 10 * the elements that are needed to create an annotated Command.
Chris@0 11 */
Chris@0 12 class BespokeDocBlockParser
Chris@0 13 {
Chris@0 14 protected $fqcnCache;
Chris@0 15
Chris@0 16 /**
Chris@0 17 * @var array
Chris@0 18 */
Chris@0 19 protected $tagProcessors = [
Chris@0 20 'command' => 'processCommandTag',
Chris@0 21 'name' => 'processCommandTag',
Chris@0 22 'arg' => 'processArgumentTag',
Chris@17 23 'param' => 'processParamTag',
Chris@0 24 'return' => 'processReturnTag',
Chris@0 25 'option' => 'processOptionTag',
Chris@0 26 'default' => 'processDefaultTag',
Chris@0 27 'aliases' => 'processAliases',
Chris@0 28 'usage' => 'processUsageTag',
Chris@0 29 'description' => 'processAlternateDescriptionTag',
Chris@0 30 'desc' => 'processAlternateDescriptionTag',
Chris@0 31 ];
Chris@0 32
Chris@0 33 public function __construct(CommandInfo $commandInfo, \ReflectionMethod $reflection, $fqcnCache = null)
Chris@0 34 {
Chris@0 35 $this->commandInfo = $commandInfo;
Chris@0 36 $this->reflection = $reflection;
Chris@0 37 $this->fqcnCache = $fqcnCache ?: new FullyQualifiedClassCache();
Chris@0 38 }
Chris@0 39
Chris@0 40 /**
Chris@0 41 * Parse the docBlock comment for this command, and set the
Chris@0 42 * fields of this class with the data thereby obtained.
Chris@0 43 */
Chris@0 44 public function parse()
Chris@0 45 {
Chris@0 46 $doc = $this->reflection->getDocComment();
Chris@0 47 $this->parseDocBlock($doc);
Chris@0 48 }
Chris@0 49
Chris@0 50 /**
Chris@0 51 * Save any tag that we do not explicitly recognize in the
Chris@0 52 * 'otherAnnotations' map.
Chris@0 53 */
Chris@0 54 protected function processGenericTag($tag)
Chris@0 55 {
Chris@0 56 $this->commandInfo->addAnnotation($tag->getTag(), $tag->getContent());
Chris@0 57 }
Chris@0 58
Chris@0 59 /**
Chris@0 60 * Set the name of the command from a @command or @name annotation.
Chris@0 61 */
Chris@0 62 protected function processCommandTag($tag)
Chris@0 63 {
Chris@0 64 if (!$tag->hasWordAndDescription($matches)) {
Chris@0 65 throw new \Exception('Could not determine command name from tag ' . (string)$tag);
Chris@0 66 }
Chris@0 67 $commandName = $matches['word'];
Chris@0 68 $this->commandInfo->setName($commandName);
Chris@0 69 // We also store the name in the 'other annotations' so that is is
Chris@0 70 // possible to determine if the method had a @command annotation.
Chris@0 71 $this->commandInfo->addAnnotation($tag->getTag(), $commandName);
Chris@0 72 }
Chris@0 73
Chris@0 74 /**
Chris@0 75 * The @description and @desc annotations may be used in
Chris@0 76 * place of the synopsis (which we call 'description').
Chris@0 77 * This is discouraged.
Chris@0 78 *
Chris@0 79 * @deprecated
Chris@0 80 */
Chris@0 81 protected function processAlternateDescriptionTag($tag)
Chris@0 82 {
Chris@0 83 $this->commandInfo->setDescription($tag->getContent());
Chris@0 84 }
Chris@0 85
Chris@0 86 /**
Chris@17 87 * Store the data from a @param annotation in our argument descriptions.
Chris@17 88 */
Chris@17 89 protected function processParamTag($tag)
Chris@17 90 {
Chris@17 91 if ($tag->hasTypeVariableAndDescription($matches)) {
Chris@17 92 if ($this->ignoredParamType($matches['type'])) {
Chris@17 93 return;
Chris@17 94 }
Chris@17 95 }
Chris@17 96 return $this->processArgumentTag($tag);
Chris@17 97 }
Chris@17 98
Chris@17 99 protected function ignoredParamType($paramType)
Chris@17 100 {
Chris@17 101 // TODO: We should really only allow a couple of types here,
Chris@17 102 // e.g. 'string', 'array', 'bool'. Blacklist things we do not
Chris@17 103 // want for now to avoid breaking commands with weird types.
Chris@17 104 // Fix in the next major version.
Chris@17 105 //
Chris@17 106 // This works:
Chris@17 107 // return !in_array($paramType, ['string', 'array', 'integer', 'bool']);
Chris@17 108 return preg_match('#(InputInterface|OutputInterface)$#', $paramType);
Chris@17 109 }
Chris@17 110
Chris@17 111 /**
Chris@0 112 * Store the data from a @arg annotation in our argument descriptions.
Chris@0 113 */
Chris@0 114 protected function processArgumentTag($tag)
Chris@0 115 {
Chris@0 116 if (!$tag->hasVariable($matches)) {
Chris@0 117 throw new \Exception('Could not determine argument name from tag ' . (string)$tag);
Chris@0 118 }
Chris@0 119 if ($matches['variable'] == $this->optionParamName()) {
Chris@0 120 return;
Chris@0 121 }
Chris@0 122 $this->addOptionOrArgumentTag($tag, $this->commandInfo->arguments(), $matches['variable'], $matches['description']);
Chris@0 123 }
Chris@0 124
Chris@0 125 /**
Chris@0 126 * Store the data from an @option annotation in our option descriptions.
Chris@0 127 */
Chris@0 128 protected function processOptionTag($tag)
Chris@0 129 {
Chris@0 130 if (!$tag->hasVariable($matches)) {
Chris@0 131 throw new \Exception('Could not determine option name from tag ' . (string)$tag);
Chris@0 132 }
Chris@0 133 $this->addOptionOrArgumentTag($tag, $this->commandInfo->options(), $matches['variable'], $matches['description']);
Chris@0 134 }
Chris@0 135
Chris@0 136 protected function addOptionOrArgumentTag($tag, DefaultsWithDescriptions $set, $name, $description)
Chris@0 137 {
Chris@0 138 $variableName = $this->commandInfo->findMatchingOption($name);
Chris@0 139 $description = static::removeLineBreaks($description);
Chris@18 140 list($description, $defaultValue) = $this->splitOutDefault($description);
Chris@0 141 $set->add($variableName, $description);
Chris@18 142 if ($defaultValue !== null) {
Chris@18 143 $set->setDefaultValue($variableName, $defaultValue);
Chris@18 144 }
Chris@18 145 }
Chris@18 146
Chris@18 147 protected function splitOutDefault($description)
Chris@18 148 {
Chris@18 149 if (!preg_match('#(.*)(Default: *)(.*)#', trim($description), $matches)) {
Chris@18 150 return [$description, null];
Chris@18 151 }
Chris@18 152
Chris@18 153 return [trim($matches[1]), $this->interpretDefaultValue(trim($matches[3]))];
Chris@0 154 }
Chris@0 155
Chris@0 156 /**
Chris@0 157 * Store the data from a @default annotation in our argument or option store,
Chris@0 158 * as appropriate.
Chris@0 159 */
Chris@0 160 protected function processDefaultTag($tag)
Chris@0 161 {
Chris@0 162 if (!$tag->hasVariable($matches)) {
Chris@0 163 throw new \Exception('Could not determine parameter name for default value from tag ' . (string)$tag);
Chris@0 164 }
Chris@0 165 $variableName = $matches['variable'];
Chris@0 166 $defaultValue = $this->interpretDefaultValue($matches['description']);
Chris@0 167 if ($this->commandInfo->arguments()->exists($variableName)) {
Chris@0 168 $this->commandInfo->arguments()->setDefaultValue($variableName, $defaultValue);
Chris@0 169 return;
Chris@0 170 }
Chris@0 171 $variableName = $this->commandInfo->findMatchingOption($variableName);
Chris@0 172 if ($this->commandInfo->options()->exists($variableName)) {
Chris@0 173 $this->commandInfo->options()->setDefaultValue($variableName, $defaultValue);
Chris@0 174 }
Chris@0 175 }
Chris@0 176
Chris@0 177 /**
Chris@0 178 * Store the data from a @usage annotation in our example usage list.
Chris@0 179 */
Chris@0 180 protected function processUsageTag($tag)
Chris@0 181 {
Chris@0 182 $lines = explode("\n", $tag->getContent());
Chris@0 183 $usage = trim(array_shift($lines));
Chris@0 184 $description = static::removeLineBreaks(implode("\n", array_map(function ($line) {
Chris@0 185 return trim($line);
Chris@0 186 }, $lines)));
Chris@0 187
Chris@0 188 $this->commandInfo->setExampleUsage($usage, $description);
Chris@0 189 }
Chris@0 190
Chris@0 191 /**
Chris@0 192 * Process the comma-separated list of aliases
Chris@0 193 */
Chris@0 194 protected function processAliases($tag)
Chris@0 195 {
Chris@0 196 $this->commandInfo->setAliases((string)$tag->getContent());
Chris@0 197 }
Chris@0 198
Chris@0 199 /**
Chris@0 200 * Store the data from a @return annotation in our argument descriptions.
Chris@0 201 */
Chris@0 202 protected function processReturnTag($tag)
Chris@0 203 {
Chris@0 204 // The return type might be a variable -- '$this'. It will
Chris@0 205 // usually be a type, like RowsOfFields, or \Namespace\RowsOfFields.
Chris@0 206 if (!$tag->hasVariableAndDescription($matches)) {
Chris@0 207 throw new \Exception('Could not determine return type from tag ' . (string)$tag);
Chris@0 208 }
Chris@0 209 // Look at namespace and `use` statments to make returnType a fqdn
Chris@0 210 $returnType = $matches['variable'];
Chris@0 211 $returnType = $this->findFullyQualifiedClass($returnType);
Chris@0 212 $this->commandInfo->setReturnType($returnType);
Chris@0 213 }
Chris@0 214
Chris@0 215 protected function findFullyQualifiedClass($className)
Chris@0 216 {
Chris@0 217 if (strpos($className, '\\') !== false) {
Chris@0 218 return $className;
Chris@0 219 }
Chris@0 220
Chris@0 221 return $this->fqcnCache->qualify($this->reflection->getFileName(), $className);
Chris@0 222 }
Chris@0 223
Chris@0 224 private function parseDocBlock($doc)
Chris@0 225 {
Chris@0 226 // Remove the leading /** and the trailing */
Chris@0 227 $doc = preg_replace('#^\s*/\*+\s*#', '', $doc);
Chris@0 228 $doc = preg_replace('#\s*\*+/\s*#', '', $doc);
Chris@0 229
Chris@0 230 // Nothing left? Exit.
Chris@0 231 if (empty($doc)) {
Chris@0 232 return;
Chris@0 233 }
Chris@0 234
Chris@0 235 $tagFactory = new TagFactory();
Chris@0 236 $lines = [];
Chris@0 237
Chris@0 238 foreach (explode("\n", $doc) as $row) {
Chris@0 239 // Remove trailing whitespace and leading space + '*'s
Chris@0 240 $row = rtrim($row);
Chris@0 241 $row = preg_replace('#^[ \t]*\**#', '', $row);
Chris@0 242
Chris@0 243 if (!$tagFactory->parseLine($row)) {
Chris@0 244 $lines[] = $row;
Chris@0 245 }
Chris@0 246 }
Chris@0 247
Chris@0 248 $this->processDescriptionAndHelp($lines);
Chris@0 249 $this->processAllTags($tagFactory->getTags());
Chris@0 250 }
Chris@0 251
Chris@0 252 protected function processDescriptionAndHelp($lines)
Chris@0 253 {
Chris@0 254 // Trim all of the lines individually.
Chris@0 255 $lines =
Chris@0 256 array_map(
Chris@0 257 function ($line) {
Chris@0 258 return trim($line);
Chris@0 259 },
Chris@0 260 $lines
Chris@0 261 );
Chris@0 262
Chris@0 263 // Everything up to the first blank line goes in the description.
Chris@0 264 $description = array_shift($lines);
Chris@0 265 while ($this->nextLineIsNotEmpty($lines)) {
Chris@0 266 $description .= ' ' . array_shift($lines);
Chris@0 267 }
Chris@0 268
Chris@0 269 // Everything else goes in the help.
Chris@0 270 $help = trim(implode("\n", $lines));
Chris@0 271
Chris@0 272 $this->commandInfo->setDescription($description);
Chris@0 273 $this->commandInfo->setHelp($help);
Chris@0 274 }
Chris@0 275
Chris@0 276 protected function nextLineIsNotEmpty($lines)
Chris@0 277 {
Chris@0 278 if (empty($lines)) {
Chris@0 279 return false;
Chris@0 280 }
Chris@0 281
Chris@0 282 $nextLine = trim($lines[0]);
Chris@0 283 return !empty($nextLine);
Chris@0 284 }
Chris@0 285
Chris@0 286 protected function processAllTags($tags)
Chris@0 287 {
Chris@0 288 // Iterate over all of the tags, and process them as necessary.
Chris@0 289 foreach ($tags as $tag) {
Chris@0 290 $processFn = [$this, 'processGenericTag'];
Chris@0 291 if (array_key_exists($tag->getTag(), $this->tagProcessors)) {
Chris@0 292 $processFn = [$this, $this->tagProcessors[$tag->getTag()]];
Chris@0 293 }
Chris@0 294 $processFn($tag);
Chris@0 295 }
Chris@0 296 }
Chris@0 297
Chris@0 298 protected function lastParameterName()
Chris@0 299 {
Chris@0 300 $params = $this->commandInfo->getParameters();
Chris@0 301 $param = end($params);
Chris@0 302 if (!$param) {
Chris@0 303 return '';
Chris@0 304 }
Chris@0 305 return $param->name;
Chris@0 306 }
Chris@0 307
Chris@0 308 /**
Chris@0 309 * Return the name of the last parameter if it holds the options.
Chris@0 310 */
Chris@0 311 public function optionParamName()
Chris@0 312 {
Chris@0 313 // Remember the name of the last parameter, if it holds the options.
Chris@0 314 // We will use this information to ignore @param annotations for the options.
Chris@0 315 if (!isset($this->optionParamName)) {
Chris@0 316 $this->optionParamName = '';
Chris@0 317 $options = $this->commandInfo->options();
Chris@0 318 if (!$options->isEmpty()) {
Chris@0 319 $this->optionParamName = $this->lastParameterName();
Chris@0 320 }
Chris@0 321 }
Chris@0 322
Chris@0 323 return $this->optionParamName;
Chris@0 324 }
Chris@0 325
Chris@0 326 protected function interpretDefaultValue($defaultValue)
Chris@0 327 {
Chris@0 328 $defaults = [
Chris@0 329 'null' => null,
Chris@0 330 'true' => true,
Chris@0 331 'false' => false,
Chris@0 332 "''" => '',
Chris@0 333 '[]' => [],
Chris@0 334 ];
Chris@0 335 foreach ($defaults as $defaultName => $defaultTypedValue) {
Chris@0 336 if ($defaultValue == $defaultName) {
Chris@0 337 return $defaultTypedValue;
Chris@0 338 }
Chris@0 339 }
Chris@0 340 return $defaultValue;
Chris@0 341 }
Chris@0 342
Chris@0 343 /**
Chris@0 344 * Given a list that might be 'a b c' or 'a, b, c' or 'a,b,c',
Chris@0 345 * convert the data into the last of these forms.
Chris@0 346 */
Chris@0 347 protected static function convertListToCommaSeparated($text)
Chris@0 348 {
Chris@0 349 return preg_replace('#[ \t\n\r,]+#', ',', $text);
Chris@0 350 }
Chris@0 351
Chris@0 352 /**
Chris@0 353 * Take a multiline description and convert it into a single
Chris@0 354 * long unbroken line.
Chris@0 355 */
Chris@0 356 protected static function removeLineBreaks($text)
Chris@0 357 {
Chris@0 358 return trim(preg_replace('#[ \t\n\r]+#', ' ', $text));
Chris@0 359 }
Chris@0 360 }