Passed
Push — latest ( 5ac9c7...097d48 )
by Colin
157:29 queued 46:39
created

src/Parser/MarkdownParser.php (1 issue)

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the league/commonmark package.
7
 *
8
 * (c) Colin O'Dell <[email protected]>
9
 *
10
 * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
11
 *  - (c) John MacFarlane
12
 *
13
 * Additional code based on commonmark-java (https://github.com/commonmark/commonmark-java)
14
 *  - (c) Atlassian Pty Ltd
15
 *
16
 * For the full copyright and license information, please view the LICENSE
17
 * file that was distributed with this source code.
18
 */
19
20
namespace League\CommonMark\Parser;
21
22
use League\CommonMark\Environment\EnvironmentInterface;
23
use League\CommonMark\Event\DocumentParsedEvent;
24
use League\CommonMark\Event\DocumentPreParsedEvent;
25
use League\CommonMark\Input\MarkdownInput;
26
use League\CommonMark\Node\Block\Document;
27
use League\CommonMark\Node\Block\Paragraph;
28
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
29
use League\CommonMark\Parser\Block\BlockContinueParserWithInlinesInterface;
30
use League\CommonMark\Parser\Block\BlockStart;
31
use League\CommonMark\Parser\Block\BlockStartParserInterface;
32
use League\CommonMark\Parser\Block\DocumentBlockParser;
33
use League\CommonMark\Parser\Block\ParagraphParser;
34
use League\CommonMark\Reference\ReferenceInterface;
35
use League\CommonMark\Reference\ReferenceMap;
36
use League\CommonMark\Util\RegexHelper;
37
38
final class MarkdownParser implements MarkdownParserInterface
39
{
40
    /**
41
     * @var EnvironmentInterface
42
     *
43
     * @psalm-readonly
44
     */
45
    private $environment;
46
47
    /**
48
     * @var int
49
     *
50
     * @psalm-readonly-allow-private-mutation
51
     */
52
    private $maxNestingLevel;
53
54
    /**
55
     * @var ReferenceMap
56
     *
57
     * @psalm-readonly-allow-private-mutation
58
     */
59
    private $referenceMap;
60
61
    /**
62
     * @var int
63
     *
64
     * @psalm-readonly-allow-private-mutation
65
     */
66
    private $lineNumber = 0;
67
68
    /**
69
     * @var Cursor
70
     *
71
     * @psalm-readonly-allow-private-mutation
72
     */
73
    private $cursor;
74
75
    /**
76
     * @var array<int, BlockContinueParserInterface>
77
     *
78
     * @psalm-readonly-allow-private-mutation
79
     */
80
    private $activeBlockParsers = [];
81
82
    /**
83
     * @var array<int, BlockContinueParserInterface>
84
     *
85
     * @psalm-readonly-allow-private-mutation
86
     */
87
    private $closedBlockParsers = [];
88
89 2982
    public function __construct(EnvironmentInterface $environment)
90
    {
91 2982
        $this->environment = $environment;
92 2982
    }
93
94 2964
    private function initialize(): void
95
    {
96 2964
        $this->referenceMap       = new ReferenceMap();
97 2964
        $this->lineNumber         = 0;
98 2964
        $this->activeBlockParsers = [];
99 2964
        $this->closedBlockParsers = [];
100
101 2964
        $this->maxNestingLevel = $this->environment->getConfiguration()->get('max_nesting_level');
102 2952
    }
103
104
    /**
105
     * @throws \RuntimeException
106
     */
107 2964
    public function parse(string $input): Document
108
    {
109 2964
        $this->initialize();
110
111 2952
        $documentParser = new DocumentBlockParser($this->referenceMap);
112 2952
        $this->activateBlockParser($documentParser);
113
114 2952
        $preParsedEvent = new DocumentPreParsedEvent($documentParser->getBlock(), new MarkdownInput($input));
115 2943
        $this->environment->dispatch($preParsedEvent);
116 2940
        $markdownInput = $preParsedEvent->getMarkdown();
117
118 2940
        foreach ($markdownInput->getLines() as $lineNumber => $line) {
119 2940
            $this->lineNumber = $lineNumber;
120 2940
            $this->incorporateLine($line);
121
        }
122
123
        // finalizeAndProcess
124 2940
        $this->closeBlockParsers(\count($this->activeBlockParsers), $this->lineNumber);
125 2940
        $this->processInlines();
126
127 2940
        $this->environment->dispatch(new DocumentParsedEvent($documentParser->getBlock()));
128
129 2940
        return $documentParser->getBlock();
130
    }
131
132
    /**
133
     * Analyze a line of text and update the document appropriately. We parse markdown text by calling this on each
134
     * line of input, then finalizing the document.
135
     */
136 2940
    private function incorporateLine(string $line): void
137
    {
138 2940
        $this->cursor = new Cursor($line);
139
140 2940
        $matches = 1;
141 2940
        for ($i = 1; $i < \count($this->activeBlockParsers); $i++) {
1 ignored issue
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
142 1437
            $blockParser   = $this->activeBlockParsers[$i];
143 1437
            $blockContinue = $blockParser->tryContinue(clone $this->cursor, $this->getActiveBlockParser());
144 1437
            if ($blockContinue === null) {
145 945
                break;
146
            }
147
148 963
            if ($blockContinue->isFinalize()) {
149 90
                $this->closeBlockParsers(\count($this->activeBlockParsers) - $i, $this->lineNumber);
150
151 90
                return;
152
            }
153
154 957
            if (($state = $blockContinue->getCursorState()) !== null) {
155 957
                $this->cursor->restoreState($state);
156
            }
157
158 957
            $matches++;
159
        }
160
161 2940
        $unmatchedBlocks = \count($this->activeBlockParsers) - $matches;
162 2940
        $blockParser     = $this->activeBlockParsers[$matches - 1];
163 2940
        $startedNewBlock = false;
164
165
        // Unless last matched container is a code block, try new container starts
166 2940
        $tryBlockStarts = $blockParser->getBlock() instanceof Paragraph || $blockParser->isContainer();
167 2940
        while ($tryBlockStarts) {
168
            // this is a little performance optimization
169 2940
            if ($this->cursor->isBlank()) {
170 738
                $this->cursor->advanceToEnd();
171 738
                break;
172
            }
173
174 2940
            if (! $this->cursor->isIndented() && RegexHelper::isLetter($this->cursor->getNextNonSpaceCharacter())) {
175 1395
                $this->cursor->advanceToNextNonSpaceOrTab();
176 1395
                break;
177
            }
178
179 2298
            if ($blockParser->getBlock()->getDepth() >= $this->maxNestingLevel) {
180 3
                break;
181
            }
182
183 2298
            $blockStart = $this->findBlockStart($blockParser);
184 2298
            if ($blockStart === null) {
185 1377
                $this->cursor->advanceToNextNonSpaceOrTab();
186 1377
                break;
187
            }
188
189 1080
            if (($state = $blockStart->getCursorState()) !== null) {
190 1080
                $this->cursor->restoreState($state);
191
            }
192
193 1080
            $startedNewBlock = true;
194
195
            // We're starting a new block. If we have any previous blocks that need to be closed, we need to do it now.
196 1080
            if ($unmatchedBlocks > 0) {
197 258
                $this->closeBlockParsers($unmatchedBlocks, $this->lineNumber - 1);
198 258
                $unmatchedBlocks = 0;
199
            }
200
201 1080
            if ($blockStart->isReplaceActiveBlockParser()) {
202 150
                $this->prepareActiveBlockParserForReplacement();
203
            }
204
205 1080
            foreach ($blockStart->getBlockParsers() as $newBlockParser) {
206 1080
                $blockParser    = $this->addChild($newBlockParser);
207 1080
                $tryBlockStarts = $newBlockParser->isContainer();
208
            }
209
        }
210
211
        // What remains ath the offset is a text line. Add the text to the appropriate block.
212
213
        // First check for a lazy paragraph continuation:
214 2940
        if (! $startedNewBlock && ! $this->cursor->isBlank() && $this->getActiveBlockParser()->canHaveLazyContinuationLines()) {
215 447
            $this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
216
        } else {
217
            // finalize any blocks not matched
218 2940
            if ($unmatchedBlocks > 0) {
219 777
                $this->closeBlockParsers($unmatchedBlocks, $this->lineNumber);
220
            }
221
222 2940
            if (! $blockParser->isContainer()) {
223 780
                $this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
224 2613
            } elseif (! $this->cursor->isBlank()) {
225 2562
                $this->addChild(new ParagraphParser());
226 2562
                $this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
227
            }
228
        }
229 2940
    }
230
231 2298
    private function findBlockStart(BlockContinueParserInterface $lastMatchedBlockParser): ?BlockStart
232
    {
233 2298
        $matchedBlockParser = new MarkdownParserState($this->getActiveBlockParser(), $lastMatchedBlockParser);
234
235 2298
        foreach ($this->environment->getBlockStartParsers() as $blockStartParser) {
236
            \assert($blockStartParser instanceof BlockStartParserInterface);
237 2295
            if (($result = $blockStartParser->tryStart(clone $this->cursor, $matchedBlockParser)) !== null) {
238 1080
                return $result;
239
            }
240
        }
241
242 1377
        return null;
243
    }
244
245 2940
    private function closeBlockParsers(int $count, int $endLineNumber): void
246
    {
247 2940
        for ($i = 0; $i < $count; $i++) {
248 2940
            $blockParser = $this->deactivateBlockParser();
249 2940
            $this->finalize($blockParser, $endLineNumber);
250
            // Remember for inline parsing
251 2940
            $this->closedBlockParsers[] = $blockParser;
252
        }
253 2940
    }
254
255
    /**
256
     * Finalize a block. Close it and do any necessary postprocessing, e.g. creating string_content from strings,
257
     * setting the 'tight' or 'loose' status of a list, and parsing the beginnings of paragraphs for reference
258
     * definitions.
259
     */
260 2940
    private function finalize(BlockContinueParserInterface $blockParser, int $endLineNumber): void
261
    {
262 2940
        if ($blockParser instanceof ParagraphParser) {
263 2454
            $this->updateReferenceMap($blockParser->getReferences());
264
        }
265
266 2940
        $blockParser->getBlock()->setEndLine($endLineNumber);
267 2940
        $blockParser->closeBlock();
268 2940
    }
269
270
    /**
271
     * Walk through a block & children recursively, parsing string content into inline content where appropriate.
272
     */
273 2940
    private function processInlines(): void
274
    {
275 2940
        $p = new InlineParserEngine($this->environment, $this->referenceMap);
276
277 2940
        foreach ($this->closedBlockParsers as $blockParser) {
278 2940
            if ($blockParser instanceof BlockContinueParserWithInlinesInterface) {
279 2652
                $blockParser->parseInlines($p);
280
            }
281
        }
282 2940
    }
283
284
    /**
285
     * Add block of type tag as a child of the tip. If the tip can't accept children, close and finalize it and try
286
     * its parent, and so on til we find a block that can accept children.
287
     */
288 2940
    private function addChild(BlockContinueParserInterface $blockParser): BlockContinueParserInterface
289
    {
290 2940
        $blockParser->getBlock()->setStartLine($this->lineNumber);
291
292 2940
        while (! $this->getActiveBlockParser()->canContain($blockParser->getBlock())) {
293 141
            $this->closeBlockParsers(1, $this->lineNumber - 1);
294
        }
295
296 2940
        $this->getActiveBlockParser()->getBlock()->appendChild($blockParser->getBlock());
297 2940
        $this->activateBlockParser($blockParser);
298
299 2940
        return $blockParser;
300
    }
301
302 2952
    private function activateBlockParser(BlockContinueParserInterface $blockParser): void
303
    {
304 2952
        $this->activeBlockParsers[] = $blockParser;
305 2952
    }
306
307 2940
    private function deactivateBlockParser(): BlockContinueParserInterface
308
    {
309 2940
        $popped = \array_pop($this->activeBlockParsers);
310 2940
        if ($popped === null) {
311
            throw new \RuntimeException('The last block parser should not be deactivated');
312
        }
313
314 2940
        return $popped;
315
    }
316
317 150
    private function prepareActiveBlockParserForReplacement(): void
318
    {
319
        // Note that we don't want to parse inlines or finalize this block, as it's getting replaced.
320 150
        $old = $this->deactivateBlockParser();
321
322 150
        if ($old instanceof ParagraphParser) {
323 150
            $this->updateReferenceMap($old->getReferences());
324
        }
325
326 150
        $old->getBlock()->detach();
327 150
    }
328
329
    /**
330
     * @param ReferenceInterface[] $references
331
     */
332 2562
    private function updateReferenceMap(iterable $references): void
333
    {
334 2562
        foreach ($references as $reference) {
335 237
            if (! $this->referenceMap->contains($reference->getLabel())) {
336 237
                $this->referenceMap->add($reference);
337
            }
338
        }
339 2562
    }
340
341 2940
    public function getActiveBlockParser(): BlockContinueParserInterface
342
    {
343 2940
        $active = \end($this->activeBlockParsers);
344 2940
        if ($active === false) {
345
            throw new \RuntimeException('No active block parsers are available');
346
        }
347
348 2940
        return $active;
349
    }
350
}
351