DocBlockFactory.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * This file is part of phpDocumentor.
  5. *
  6. * For the full copyright and license information, please view the LICENSE
  7. * file that was distributed with this source code.
  8. *
  9. * @link http://phpdoc.org
  10. */
  11. namespace phpDocumentor\Reflection;
  12. use InvalidArgumentException;
  13. use LogicException;
  14. use phpDocumentor\Reflection\DocBlock\DescriptionFactory;
  15. use phpDocumentor\Reflection\DocBlock\StandardTagFactory;
  16. use phpDocumentor\Reflection\DocBlock\Tag;
  17. use phpDocumentor\Reflection\DocBlock\TagFactory;
  18. use Webmozart\Assert\Assert;
  19. use function array_shift;
  20. use function count;
  21. use function explode;
  22. use function is_object;
  23. use function method_exists;
  24. use function preg_match;
  25. use function preg_replace;
  26. use function str_replace;
  27. use function strpos;
  28. use function substr;
  29. use function trim;
  30. final class DocBlockFactory implements DocBlockFactoryInterface
  31. {
  32. /** @var DocBlock\DescriptionFactory */
  33. private $descriptionFactory;
  34. /** @var DocBlock\TagFactory */
  35. private $tagFactory;
  36. /**
  37. * Initializes this factory with the required subcontractors.
  38. */
  39. public function __construct(DescriptionFactory $descriptionFactory, TagFactory $tagFactory)
  40. {
  41. $this->descriptionFactory = $descriptionFactory;
  42. $this->tagFactory = $tagFactory;
  43. }
  44. /**
  45. * Factory method for easy instantiation.
  46. *
  47. * @param array<string, class-string<Tag>> $additionalTags
  48. */
  49. public static function createInstance(array $additionalTags = []) : self
  50. {
  51. $fqsenResolver = new FqsenResolver();
  52. $tagFactory = new StandardTagFactory($fqsenResolver);
  53. $descriptionFactory = new DescriptionFactory($tagFactory);
  54. $tagFactory->addService($descriptionFactory);
  55. $tagFactory->addService(new TypeResolver($fqsenResolver));
  56. $docBlockFactory = new self($descriptionFactory, $tagFactory);
  57. foreach ($additionalTags as $tagName => $tagHandler) {
  58. $docBlockFactory->registerTagHandler($tagName, $tagHandler);
  59. }
  60. return $docBlockFactory;
  61. }
  62. /**
  63. * @param object|string $docblock A string containing the DocBlock to parse or an object supporting the
  64. * getDocComment method (such as a ReflectionClass object).
  65. */
  66. public function create($docblock, ?Types\Context $context = null, ?Location $location = null) : DocBlock
  67. {
  68. if (is_object($docblock)) {
  69. if (!method_exists($docblock, 'getDocComment')) {
  70. $exceptionMessage = 'Invalid object passed; the given object must support the getDocComment method';
  71. throw new InvalidArgumentException($exceptionMessage);
  72. }
  73. $docblock = $docblock->getDocComment();
  74. Assert::string($docblock);
  75. }
  76. Assert::stringNotEmpty($docblock);
  77. if ($context === null) {
  78. $context = new Types\Context('');
  79. }
  80. $parts = $this->splitDocBlock($this->stripDocComment($docblock));
  81. [$templateMarker, $summary, $description, $tags] = $parts;
  82. return new DocBlock(
  83. $summary,
  84. $description ? $this->descriptionFactory->create($description, $context) : null,
  85. $this->parseTagBlock($tags, $context),
  86. $context,
  87. $location,
  88. $templateMarker === '#@+',
  89. $templateMarker === '#@-'
  90. );
  91. }
  92. /**
  93. * @param class-string<Tag> $handler
  94. */
  95. public function registerTagHandler(string $tagName, string $handler) : void
  96. {
  97. $this->tagFactory->registerTagHandler($tagName, $handler);
  98. }
  99. /**
  100. * Strips the asterisks from the DocBlock comment.
  101. *
  102. * @param string $comment String containing the comment text.
  103. */
  104. private function stripDocComment(string $comment) : string
  105. {
  106. $comment = preg_replace('#[ \t]*(?:\/\*\*|\*\/|\*)?[ \t]?(.*)?#u', '$1', $comment);
  107. Assert::string($comment);
  108. $comment = trim($comment);
  109. // reg ex above is not able to remove */ from a single line docblock
  110. if (substr($comment, -2) === '*/') {
  111. $comment = trim(substr($comment, 0, -2));
  112. }
  113. return str_replace(["\r\n", "\r"], "\n", $comment);
  114. }
  115. // phpcs:disable
  116. /**
  117. * Splits the DocBlock into a template marker, summary, description and block of tags.
  118. *
  119. * @param string $comment Comment to split into the sub-parts.
  120. *
  121. * @return string[] containing the template marker (if any), summary, description and a string containing the tags.
  122. *
  123. * @author Mike van Riel <me@mikevanriel.com> for extending the regex with template marker support.
  124. *
  125. * @author Richard van Velzen (@_richardJ) Special thanks to Richard for the regex responsible for the split.
  126. */
  127. private function splitDocBlock(string $comment) : array
  128. {
  129. // phpcs:enable
  130. // Performance improvement cheat: if the first character is an @ then only tags are in this DocBlock. This
  131. // method does not split tags so we return this verbatim as the fourth result (tags). This saves us the
  132. // performance impact of running a regular expression
  133. if (strpos($comment, '@') === 0) {
  134. return ['', '', '', $comment];
  135. }
  136. // clears all extra horizontal whitespace from the line endings to prevent parsing issues
  137. $comment = preg_replace('/\h*$/Sum', '', $comment);
  138. Assert::string($comment);
  139. /*
  140. * Splits the docblock into a template marker, summary, description and tags section.
  141. *
  142. * - The template marker is empty, #@+ or #@- if the DocBlock starts with either of those (a newline may
  143. * occur after it and will be stripped).
  144. * - The short description is started from the first character until a dot is encountered followed by a
  145. * newline OR two consecutive newlines (horizontal whitespace is taken into account to consider spacing
  146. * errors). This is optional.
  147. * - The long description, any character until a new line is encountered followed by an @ and word
  148. * characters (a tag). This is optional.
  149. * - Tags; the remaining characters
  150. *
  151. * Big thanks to RichardJ for contributing this Regular Expression
  152. */
  153. preg_match(
  154. '/
  155. \A
  156. # 1. Extract the template marker
  157. (?:(\#\@\+|\#\@\-)\n?)?
  158. # 2. Extract the summary
  159. (?:
  160. (?! @\pL ) # The summary may not start with an @
  161. (
  162. [^\n.]+
  163. (?:
  164. (?! \. \n | \n{2} ) # End summary upon a dot followed by newline or two newlines
  165. [\n.]* (?! [ \t]* @\pL ) # End summary when an @ is found as first character on a new line
  166. [^\n.]+ # Include anything else
  167. )*
  168. \.?
  169. )?
  170. )
  171. # 3. Extract the description
  172. (?:
  173. \s* # Some form of whitespace _must_ precede a description because a summary must be there
  174. (?! @\pL ) # The description may not start with an @
  175. (
  176. [^\n]+
  177. (?: \n+
  178. (?! [ \t]* @\pL ) # End description when an @ is found as first character on a new line
  179. [^\n]+ # Include anything else
  180. )*
  181. )
  182. )?
  183. # 4. Extract the tags (anything that follows)
  184. (\s+ [\s\S]*)? # everything that follows
  185. /ux',
  186. $comment,
  187. $matches
  188. );
  189. array_shift($matches);
  190. while (count($matches) < 4) {
  191. $matches[] = '';
  192. }
  193. return $matches;
  194. }
  195. /**
  196. * Creates the tag objects.
  197. *
  198. * @param string $tags Tag block to parse.
  199. * @param Types\Context $context Context of the parsed Tag
  200. *
  201. * @return DocBlock\Tag[]
  202. */
  203. private function parseTagBlock(string $tags, Types\Context $context) : array
  204. {
  205. $tags = $this->filterTagBlock($tags);
  206. if ($tags === null) {
  207. return [];
  208. }
  209. $result = [];
  210. $lines = $this->splitTagBlockIntoTagLines($tags);
  211. foreach ($lines as $key => $tagLine) {
  212. $result[$key] = $this->tagFactory->create(trim($tagLine), $context);
  213. }
  214. return $result;
  215. }
  216. /**
  217. * @return string[]
  218. */
  219. private function splitTagBlockIntoTagLines(string $tags) : array
  220. {
  221. $result = [];
  222. foreach (explode("\n", $tags) as $tagLine) {
  223. if ($tagLine !== '' && strpos($tagLine, '@') === 0) {
  224. $result[] = $tagLine;
  225. } else {
  226. $result[count($result) - 1] .= "\n" . $tagLine;
  227. }
  228. }
  229. return $result;
  230. }
  231. private function filterTagBlock(string $tags) : ?string
  232. {
  233. $tags = trim($tags);
  234. if (!$tags) {
  235. return null;
  236. }
  237. if ($tags[0] !== '@') {
  238. // @codeCoverageIgnoreStart
  239. // Can't simulate this; this only happens if there is an error with the parsing of the DocBlock that
  240. // we didn't foresee.
  241. throw new LogicException('A tag block started with text instead of an at-sign(@): ' . $tags);
  242. // @codeCoverageIgnoreEnd
  243. }
  244. return $tags;
  245. }
  246. }