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