1<?php
2
3/*
4 * This file is part of the league/commonmark package.
5 *
6 * (c) Colin O'Dell <colinodell@gmail.com>
7 *
8 * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
9 *  - (c) John MacFarlane
10 *
11 * For the full copyright and license information, please view the LICENSE
12 * file that was distributed with this source code.
13 */
14
15namespace League\CommonMark\Inline\Parser;
16
17use League\CommonMark\Cursor;
18use League\CommonMark\Delimiter\DelimiterInterface;
19use League\CommonMark\EnvironmentAwareInterface;
20use League\CommonMark\EnvironmentInterface;
21use League\CommonMark\Extension\Mention\Mention;
22use League\CommonMark\Inline\AdjacentTextMerger;
23use League\CommonMark\Inline\Element\AbstractWebResource;
24use League\CommonMark\Inline\Element\Image;
25use League\CommonMark\Inline\Element\Link;
26use League\CommonMark\Inline\Element\Text;
27use League\CommonMark\InlineParserContext;
28use League\CommonMark\Reference\ReferenceInterface;
29use League\CommonMark\Reference\ReferenceMapInterface;
30use League\CommonMark\Util\LinkParserHelper;
31use League\CommonMark\Util\RegexHelper;
32
33final class CloseBracketParser implements InlineParserInterface, EnvironmentAwareInterface
34{
35    /**
36     * @var EnvironmentInterface
37     */
38    private $environment;
39
40    public function getCharacters(): array
41    {
42        return [']'];
43    }
44
45    public function parse(InlineParserContext $inlineContext): bool
46    {
47        // Look through stack of delimiters for a [ or !
48        $opener = $inlineContext->getDelimiterStack()->searchByCharacter(['[', '!']);
49        if ($opener === null) {
50            return false;
51        }
52
53        if (!$opener->isActive()) {
54            // no matched opener; remove from emphasis stack
55            $inlineContext->getDelimiterStack()->removeDelimiter($opener);
56
57            return false;
58        }
59
60        $cursor = $inlineContext->getCursor();
61
62        $startPos = $cursor->getPosition();
63        $previousState = $cursor->saveState();
64
65        $cursor->advanceBy(1);
66
67        // Check to see if we have a link/image
68        if (!($link = $this->tryParseLink($cursor, $inlineContext->getReferenceMap(), $opener, $startPos))) {
69            // No match
70            $inlineContext->getDelimiterStack()->removeDelimiter($opener); // Remove this opener from stack
71            $cursor->restoreState($previousState);
72
73            return false;
74        }
75
76        $isImage = $opener->getChar() === '!';
77
78        $inline = $this->createInline($link['url'], $link['title'], $isImage);
79        $opener->getInlineNode()->replaceWith($inline);
80        while (($label = $inline->next()) !== null) {
81            // Is there a Mention contained within this link?
82            // CommonMark does not allow nested links, so we'll restore the original text.
83            if ($label instanceof Mention) {
84                $label->replaceWith($replacement = new Text($label->getSymbol() . $label->getIdentifier()));
85                $label = $replacement;
86            }
87
88            $inline->appendChild($label);
89        }
90
91        // Process delimiters such as emphasis inside link/image
92        $delimiterStack = $inlineContext->getDelimiterStack();
93        $stackBottom = $opener->getPrevious();
94        $delimiterStack->processDelimiters($stackBottom, $this->environment->getDelimiterProcessors());
95        $delimiterStack->removeAll($stackBottom);
96
97        // Merge any adjacent Text nodes together
98        AdjacentTextMerger::mergeChildNodes($inline);
99
100        // processEmphasis will remove this and later delimiters.
101        // Now, for a link, we also remove earlier link openers (no links in links)
102        if (!$isImage) {
103            $inlineContext->getDelimiterStack()->removeEarlierMatches('[');
104        }
105
106        return true;
107    }
108
109    public function setEnvironment(EnvironmentInterface $environment)
110    {
111        $this->environment = $environment;
112    }
113
114    /**
115     * @param Cursor                $cursor
116     * @param ReferenceMapInterface $referenceMap
117     * @param DelimiterInterface    $opener
118     * @param int                   $startPos
119     *
120     * @return array<string, string>|false
121     */
122    private function tryParseLink(Cursor $cursor, ReferenceMapInterface $referenceMap, DelimiterInterface $opener, int $startPos)
123    {
124        // Check to see if we have a link/image
125        // Inline link?
126        if ($result = $this->tryParseInlineLinkAndTitle($cursor)) {
127            return $result;
128        }
129
130        if ($link = $this->tryParseReference($cursor, $referenceMap, $opener, $startPos)) {
131            return ['url' => $link->getDestination(), 'title' => $link->getTitle()];
132        }
133
134        return false;
135    }
136
137    /**
138     * @param Cursor $cursor
139     *
140     * @return array<string, string>|false
141     */
142    private function tryParseInlineLinkAndTitle(Cursor $cursor)
143    {
144        if ($cursor->getCharacter() !== '(') {
145            return false;
146        }
147
148        $previousState = $cursor->saveState();
149
150        $cursor->advanceBy(1);
151        $cursor->advanceToNextNonSpaceOrNewline();
152        if (($dest = LinkParserHelper::parseLinkDestination($cursor)) === null) {
153            $cursor->restoreState($previousState);
154
155            return false;
156        }
157
158        $cursor->advanceToNextNonSpaceOrNewline();
159
160        $title = '';
161        // make sure there's a space before the title:
162        if (\preg_match(RegexHelper::REGEX_WHITESPACE_CHAR, $cursor->peek(-1))) {
163            $title = LinkParserHelper::parseLinkTitle($cursor) ?? '';
164        }
165
166        $cursor->advanceToNextNonSpaceOrNewline();
167
168        if ($cursor->getCharacter() !== ')') {
169            $cursor->restoreState($previousState);
170
171            return false;
172        }
173
174        $cursor->advanceBy(1);
175
176        return ['url' => $dest, 'title' => $title];
177    }
178
179    private function tryParseReference(Cursor $cursor, ReferenceMapInterface $referenceMap, DelimiterInterface $opener, int $startPos): ?ReferenceInterface
180    {
181        if ($opener->getIndex() === null) {
182            return null;
183        }
184
185        $savePos = $cursor->saveState();
186        $beforeLabel = $cursor->getPosition();
187        $n = LinkParserHelper::parseLinkLabel($cursor);
188        if ($n === 0 || $n === 2) {
189            $start = $opener->getIndex();
190            $length = $startPos - $opener->getIndex();
191        } else {
192            $start = $beforeLabel + 1;
193            $length = $n - 2;
194        }
195
196        $referenceLabel = $cursor->getSubstring($start, $length);
197
198        if ($n === 0) {
199            // If shortcut reference link, rewind before spaces we skipped
200            $cursor->restoreState($savePos);
201        }
202
203        return $referenceMap->getReference($referenceLabel);
204    }
205
206    private function createInline(string $url, string $title, bool $isImage): AbstractWebResource
207    {
208        if ($isImage) {
209            return new Image($url, null, $title);
210        }
211
212        return new Link($url, null, $title);
213    }
214}
215