--- /dev/null
+<?php
+
+/*
+ * This file is part of Psy Shell.
+ *
+ * (c) 2012-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Psy\Util;
+
+/**
+ * A docblock representation.
+ *
+ * Based on PHP-DocBlock-Parser by Paul Scott:
+ *
+ * {@link http://www.github.com/icio/PHP-DocBlock-Parser}
+ *
+ * @author Paul Scott <paul@duedil.com>
+ * @author Justin Hileman <justin@justinhileman.info>
+ */
+class Docblock
+{
+ /**
+ * Tags in the docblock that have a whitespace-delimited number of parameters
+ * (such as `@param type var desc` and `@return type desc`) and the names of
+ * those parameters.
+ *
+ * @var array
+ */
+ public static $vectors = array(
+ 'throws' => array('type', 'desc'),
+ 'param' => array('type', 'var', 'desc'),
+ 'return' => array('type', 'desc'),
+ );
+
+ protected $reflector;
+
+ /**
+ * The description of the symbol.
+ *
+ * @var string
+ */
+ public $desc;
+
+ /**
+ * The tags defined in the docblock.
+ *
+ * The array has keys which are the tag names (excluding the @) and values
+ * that are arrays, each of which is an entry for the tag.
+ *
+ * In the case where the tag name is defined in {@see DocBlock::$vectors} the
+ * value within the tag-value array is an array in itself with keys as
+ * described by {@see DocBlock::$vectors}.
+ *
+ * @var array
+ */
+ public $tags;
+
+ /**
+ * The entire DocBlock comment that was parsed.
+ *
+ * @var string
+ */
+ public $comment;
+
+ /**
+ * Docblock constructor.
+ *
+ * @param \Reflector $reflector
+ */
+ public function __construct(\Reflector $reflector)
+ {
+ $this->reflector = $reflector;
+ $this->setComment($reflector->getDocComment());
+ }
+
+ /**
+ * Set and parse the docblock comment.
+ *
+ * @param string $comment The docblock
+ */
+ protected function setComment($comment)
+ {
+ $this->desc = '';
+ $this->tags = array();
+ $this->comment = $comment;
+
+ $this->parseComment($comment);
+ }
+
+ /**
+ * Find the length of the docblock prefix.
+ *
+ * @param array $lines
+ *
+ * @return int Prefix length
+ */
+ protected static function prefixLength(array $lines)
+ {
+ // find only lines with interesting things
+ $lines = array_filter($lines, function ($line) {
+ return substr($line, strspn($line, "* \t\n\r\0\x0B"));
+ });
+
+ // if we sort the lines, we only have to compare two items
+ sort($lines);
+
+ $first = reset($lines);
+ $last = end($lines);
+
+ // find the longest common substring
+ $count = min(strlen($first), strlen($last));
+ for ($i = 0; $i < $count; $i++) {
+ if ($first[$i] !== $last[$i]) {
+ return $i;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Parse the comment into the component parts and set the state of the object.
+ *
+ * @param string $comment The docblock
+ */
+ protected function parseComment($comment)
+ {
+ // Strip the opening and closing tags of the docblock
+ $comment = substr($comment, 3, -2);
+
+ // Split into arrays of lines
+ $comment = array_filter(preg_split('/\r?\n\r?/', $comment));
+
+ // Trim asterisks and whitespace from the beginning and whitespace from the end of lines
+ $prefixLength = self::prefixLength($comment);
+ $comment = array_map(function ($line) use ($prefixLength) {
+ return rtrim(substr($line, $prefixLength));
+ }, $comment);
+
+ // Group the lines together by @tags
+ $blocks = array();
+ $b = -1;
+ foreach ($comment as $line) {
+ if (self::isTagged($line)) {
+ $b++;
+ $blocks[] = array();
+ } elseif ($b === -1) {
+ $b = 0;
+ $blocks[] = array();
+ }
+ $blocks[$b][] = $line;
+ }
+
+ // Parse the blocks
+ foreach ($blocks as $block => $body) {
+ $body = trim(implode("\n", $body));
+
+ if ($block === 0 && !self::isTagged($body)) {
+ // This is the description block
+ $this->desc = $body;
+ } else {
+ // This block is tagged
+ $tag = substr(self::strTag($body), 1);
+ $body = ltrim(substr($body, strlen($tag) + 2));
+
+ if (isset(self::$vectors[$tag])) {
+ // The tagged block is a vector
+ $count = count(self::$vectors[$tag]);
+ if ($body) {
+ $parts = preg_split('/\s+/', $body, $count);
+ } else {
+ $parts = array();
+ }
+
+ // Default the trailing values
+ $parts = array_pad($parts, $count, null);
+
+ // Store as a mapped array
+ $this->tags[$tag][] = array_combine(self::$vectors[$tag], $parts);
+ } else {
+ // The tagged block is only text
+ $this->tags[$tag][] = $body;
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether or not a docblock contains a given @tag.
+ *
+ * @param string $tag The name of the @tag to check for
+ *
+ * @return bool
+ */
+ public function hasTag($tag)
+ {
+ return is_array($this->tags) && array_key_exists($tag, $this->tags);
+ }
+
+ /**
+ * The value of a tag.
+ *
+ * @param string $tag
+ *
+ * @return array
+ */
+ public function tag($tag)
+ {
+ return $this->hasTag($tag) ? $this->tags[$tag] : null;
+ }
+
+ /**
+ * Whether or not a string begins with a @tag.
+ *
+ * @param string $str
+ *
+ * @return bool
+ */
+ public static function isTagged($str)
+ {
+ return isset($str[1]) && $str[0] === '@' && ctype_alpha($str[1]);
+ }
+
+ /**
+ * The tag at the beginning of a string.
+ *
+ * @param string $str
+ *
+ * @return string|null
+ */
+ public static function strTag($str)
+ {
+ if (preg_match('/^@[a-z0-9_]+/', $str, $matches)) {
+ return $matches[0];
+ }
+ }
+}