| 148 |
lars |
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 <colinodell@gmail.com>
|
|
|
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 |
|
|
|
37 |
final class MarkdownParser implements MarkdownParserInterface
|
|
|
38 |
{
|
|
|
39 |
/** @psalm-readonly */
|
|
|
40 |
private EnvironmentInterface $environment;
|
|
|
41 |
|
|
|
42 |
/** @psalm-readonly-allow-private-mutation */
|
|
|
43 |
private int $maxNestingLevel;
|
|
|
44 |
|
|
|
45 |
/** @psalm-readonly-allow-private-mutation */
|
|
|
46 |
private ReferenceMap $referenceMap;
|
|
|
47 |
|
|
|
48 |
/** @psalm-readonly-allow-private-mutation */
|
|
|
49 |
private int $lineNumber = 0;
|
|
|
50 |
|
|
|
51 |
/** @psalm-readonly-allow-private-mutation */
|
|
|
52 |
private Cursor $cursor;
|
|
|
53 |
|
|
|
54 |
/**
|
|
|
55 |
* @var array<int, BlockContinueParserInterface>
|
|
|
56 |
*
|
|
|
57 |
* @psalm-readonly-allow-private-mutation
|
|
|
58 |
*/
|
|
|
59 |
private array $activeBlockParsers = [];
|
|
|
60 |
|
|
|
61 |
/**
|
|
|
62 |
* @var array<int, BlockContinueParserWithInlinesInterface>
|
|
|
63 |
*
|
|
|
64 |
* @psalm-readonly-allow-private-mutation
|
|
|
65 |
*/
|
|
|
66 |
private array $closedBlockParsers = [];
|
|
|
67 |
|
|
|
68 |
public function __construct(EnvironmentInterface $environment)
|
|
|
69 |
{
|
|
|
70 |
$this->environment = $environment;
|
|
|
71 |
}
|
|
|
72 |
|
|
|
73 |
private function initialize(): void
|
|
|
74 |
{
|
|
|
75 |
$this->referenceMap = new ReferenceMap();
|
|
|
76 |
$this->lineNumber = 0;
|
|
|
77 |
$this->activeBlockParsers = [];
|
|
|
78 |
$this->closedBlockParsers = [];
|
|
|
79 |
|
|
|
80 |
$this->maxNestingLevel = $this->environment->getConfiguration()->get('max_nesting_level');
|
|
|
81 |
}
|
|
|
82 |
|
|
|
83 |
/**
|
|
|
84 |
* @throws \RuntimeException
|
|
|
85 |
*/
|
|
|
86 |
public function parse(string $input): Document
|
|
|
87 |
{
|
|
|
88 |
$this->initialize();
|
|
|
89 |
|
|
|
90 |
$documentParser = new DocumentBlockParser($this->referenceMap);
|
|
|
91 |
$this->activateBlockParser($documentParser);
|
|
|
92 |
|
|
|
93 |
$preParsedEvent = new DocumentPreParsedEvent($documentParser->getBlock(), new MarkdownInput($input));
|
|
|
94 |
$this->environment->dispatch($preParsedEvent);
|
|
|
95 |
$markdownInput = $preParsedEvent->getMarkdown();
|
|
|
96 |
|
|
|
97 |
foreach ($markdownInput->getLines() as $lineNumber => $line) {
|
|
|
98 |
$this->lineNumber = $lineNumber;
|
|
|
99 |
$this->parseLine($line);
|
|
|
100 |
}
|
|
|
101 |
|
|
|
102 |
// finalizeAndProcess
|
|
|
103 |
$this->closeBlockParsers(\count($this->activeBlockParsers), $this->lineNumber);
|
|
|
104 |
$this->processInlines();
|
|
|
105 |
|
|
|
106 |
$this->environment->dispatch(new DocumentParsedEvent($documentParser->getBlock()));
|
|
|
107 |
|
|
|
108 |
return $documentParser->getBlock();
|
|
|
109 |
}
|
|
|
110 |
|
|
|
111 |
/**
|
|
|
112 |
* Analyze a line of text and update the document appropriately. We parse markdown text by calling this on each
|
|
|
113 |
* line of input, then finalizing the document.
|
|
|
114 |
*/
|
|
|
115 |
private function parseLine(string $line): void
|
|
|
116 |
{
|
|
|
117 |
$this->cursor = new Cursor($line);
|
|
|
118 |
|
|
|
119 |
$matches = $this->parseBlockContinuation();
|
|
|
120 |
if ($matches === null) {
|
|
|
121 |
return;
|
|
|
122 |
}
|
|
|
123 |
|
|
|
124 |
$unmatchedBlocks = \count($this->activeBlockParsers) - $matches;
|
|
|
125 |
$blockParser = $this->activeBlockParsers[$matches - 1];
|
|
|
126 |
$startedNewBlock = false;
|
|
|
127 |
|
|
|
128 |
// Unless last matched container is a code block, try new container starts,
|
|
|
129 |
// adding children to the last matched container:
|
|
|
130 |
$tryBlockStarts = $blockParser->getBlock() instanceof Paragraph || $blockParser->isContainer();
|
|
|
131 |
while ($tryBlockStarts) {
|
|
|
132 |
// this is a little performance optimization
|
|
|
133 |
if ($this->cursor->isBlank()) {
|
|
|
134 |
$this->cursor->advanceToEnd();
|
|
|
135 |
break;
|
|
|
136 |
}
|
|
|
137 |
|
|
|
138 |
if ($blockParser->getBlock()->getDepth() >= $this->maxNestingLevel) {
|
|
|
139 |
break;
|
|
|
140 |
}
|
|
|
141 |
|
|
|
142 |
$blockStart = $this->findBlockStart($blockParser);
|
|
|
143 |
if ($blockStart === null || $blockStart->isAborting()) {
|
|
|
144 |
$this->cursor->advanceToNextNonSpaceOrTab();
|
|
|
145 |
break;
|
|
|
146 |
}
|
|
|
147 |
|
|
|
148 |
if (($state = $blockStart->getCursorState()) !== null) {
|
|
|
149 |
$this->cursor->restoreState($state);
|
|
|
150 |
}
|
|
|
151 |
|
|
|
152 |
$startedNewBlock = true;
|
|
|
153 |
|
|
|
154 |
// We're starting a new block. If we have any previous blocks that need to be closed, we need to do it now.
|
|
|
155 |
if ($unmatchedBlocks > 0) {
|
|
|
156 |
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber - 1);
|
|
|
157 |
$unmatchedBlocks = 0;
|
|
|
158 |
}
|
|
|
159 |
|
|
|
160 |
if ($blockStart->isReplaceActiveBlockParser()) {
|
|
|
161 |
$this->prepareActiveBlockParserForReplacement();
|
|
|
162 |
}
|
|
|
163 |
|
|
|
164 |
foreach ($blockStart->getBlockParsers() as $newBlockParser) {
|
|
|
165 |
$blockParser = $this->addChild($newBlockParser);
|
|
|
166 |
$tryBlockStarts = $newBlockParser->isContainer();
|
|
|
167 |
}
|
|
|
168 |
}
|
|
|
169 |
|
|
|
170 |
// What remains at the offset is a text line. Add the text to the appropriate block.
|
|
|
171 |
|
|
|
172 |
// First check for a lazy paragraph continuation:
|
|
|
173 |
if (! $startedNewBlock && ! $this->cursor->isBlank() && $this->getActiveBlockParser()->canHaveLazyContinuationLines()) {
|
|
|
174 |
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
|
|
175 |
} else {
|
|
|
176 |
// finalize any blocks not matched
|
|
|
177 |
if ($unmatchedBlocks > 0) {
|
|
|
178 |
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber);
|
|
|
179 |
}
|
|
|
180 |
|
|
|
181 |
if (! $blockParser->isContainer()) {
|
|
|
182 |
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
|
|
183 |
} elseif (! $this->cursor->isBlank()) {
|
|
|
184 |
$this->addChild(new ParagraphParser());
|
|
|
185 |
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
|
|
186 |
}
|
|
|
187 |
}
|
|
|
188 |
}
|
|
|
189 |
|
|
|
190 |
private function parseBlockContinuation(): ?int
|
|
|
191 |
{
|
|
|
192 |
// For each containing block, try to parse the associated line start.
|
|
|
193 |
// The document will always match, so we can skip the first block parser and start at 1 matches
|
|
|
194 |
$matches = 1;
|
|
|
195 |
for ($i = 1; $i < \count($this->activeBlockParsers); $i++) {
|
|
|
196 |
$blockParser = $this->activeBlockParsers[$i];
|
|
|
197 |
$blockContinue = $blockParser->tryContinue(clone $this->cursor, $this->getActiveBlockParser());
|
|
|
198 |
if ($blockContinue === null) {
|
|
|
199 |
break;
|
|
|
200 |
}
|
|
|
201 |
|
|
|
202 |
if ($blockContinue->isFinalize()) {
|
|
|
203 |
$this->closeBlockParsers(\count($this->activeBlockParsers) - $i, $this->lineNumber);
|
|
|
204 |
|
|
|
205 |
return null;
|
|
|
206 |
}
|
|
|
207 |
|
|
|
208 |
if (($state = $blockContinue->getCursorState()) !== null) {
|
|
|
209 |
$this->cursor->restoreState($state);
|
|
|
210 |
}
|
|
|
211 |
|
|
|
212 |
$matches++;
|
|
|
213 |
}
|
|
|
214 |
|
|
|
215 |
return $matches;
|
|
|
216 |
}
|
|
|
217 |
|
|
|
218 |
private function findBlockStart(BlockContinueParserInterface $lastMatchedBlockParser): ?BlockStart
|
|
|
219 |
{
|
|
|
220 |
$matchedBlockParser = new MarkdownParserState($this->getActiveBlockParser(), $lastMatchedBlockParser);
|
|
|
221 |
|
|
|
222 |
foreach ($this->environment->getBlockStartParsers() as $blockStartParser) {
|
|
|
223 |
\assert($blockStartParser instanceof BlockStartParserInterface);
|
|
|
224 |
if (($result = $blockStartParser->tryStart(clone $this->cursor, $matchedBlockParser)) !== null) {
|
|
|
225 |
return $result;
|
|
|
226 |
}
|
|
|
227 |
}
|
|
|
228 |
|
|
|
229 |
return null;
|
|
|
230 |
}
|
|
|
231 |
|
|
|
232 |
private function closeBlockParsers(int $count, int $endLineNumber): void
|
|
|
233 |
{
|
|
|
234 |
for ($i = 0; $i < $count; $i++) {
|
|
|
235 |
$blockParser = $this->deactivateBlockParser();
|
|
|
236 |
$this->finalize($blockParser, $endLineNumber);
|
|
|
237 |
|
|
|
238 |
// phpcs:disable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
|
|
|
239 |
if ($blockParser instanceof BlockContinueParserWithInlinesInterface) {
|
|
|
240 |
// Remember for inline parsing
|
|
|
241 |
$this->closedBlockParsers[] = $blockParser;
|
|
|
242 |
}
|
|
|
243 |
}
|
|
|
244 |
}
|
|
|
245 |
|
|
|
246 |
/**
|
|
|
247 |
* Finalize a block. Close it and do any necessary postprocessing, e.g. creating string_content from strings,
|
|
|
248 |
* setting the 'tight' or 'loose' status of a list, and parsing the beginnings of paragraphs for reference
|
|
|
249 |
* definitions.
|
|
|
250 |
*/
|
|
|
251 |
private function finalize(BlockContinueParserInterface $blockParser, int $endLineNumber): void
|
|
|
252 |
{
|
|
|
253 |
if ($blockParser instanceof ParagraphParser) {
|
|
|
254 |
$this->updateReferenceMap($blockParser->getReferences());
|
|
|
255 |
}
|
|
|
256 |
|
|
|
257 |
$blockParser->getBlock()->setEndLine($endLineNumber);
|
|
|
258 |
$blockParser->closeBlock();
|
|
|
259 |
}
|
|
|
260 |
|
|
|
261 |
/**
|
|
|
262 |
* Walk through a block & children recursively, parsing string content into inline content where appropriate.
|
|
|
263 |
*/
|
|
|
264 |
private function processInlines(): void
|
|
|
265 |
{
|
|
|
266 |
$p = new InlineParserEngine($this->environment, $this->referenceMap);
|
|
|
267 |
|
|
|
268 |
foreach ($this->closedBlockParsers as $blockParser) {
|
|
|
269 |
$blockParser->parseInlines($p);
|
|
|
270 |
}
|
|
|
271 |
}
|
|
|
272 |
|
|
|
273 |
/**
|
|
|
274 |
* Add block of type tag as a child of the tip. If the tip can't accept children, close and finalize it and try
|
|
|
275 |
* its parent, and so on til we find a block that can accept children.
|
|
|
276 |
*/
|
|
|
277 |
private function addChild(BlockContinueParserInterface $blockParser): BlockContinueParserInterface
|
|
|
278 |
{
|
|
|
279 |
$blockParser->getBlock()->setStartLine($this->lineNumber);
|
|
|
280 |
|
|
|
281 |
while (! $this->getActiveBlockParser()->canContain($blockParser->getBlock())) {
|
|
|
282 |
$this->closeBlockParsers(1, $this->lineNumber - 1);
|
|
|
283 |
}
|
|
|
284 |
|
|
|
285 |
$this->getActiveBlockParser()->getBlock()->appendChild($blockParser->getBlock());
|
|
|
286 |
$this->activateBlockParser($blockParser);
|
|
|
287 |
|
|
|
288 |
return $blockParser;
|
|
|
289 |
}
|
|
|
290 |
|
|
|
291 |
private function activateBlockParser(BlockContinueParserInterface $blockParser): void
|
|
|
292 |
{
|
|
|
293 |
$this->activeBlockParsers[] = $blockParser;
|
|
|
294 |
}
|
|
|
295 |
|
|
|
296 |
private function deactivateBlockParser(): BlockContinueParserInterface
|
|
|
297 |
{
|
|
|
298 |
$popped = \array_pop($this->activeBlockParsers);
|
|
|
299 |
if ($popped === null) {
|
|
|
300 |
throw new \RuntimeException('The last block parser should not be deactivated');
|
|
|
301 |
}
|
|
|
302 |
|
|
|
303 |
return $popped;
|
|
|
304 |
}
|
|
|
305 |
|
|
|
306 |
private function prepareActiveBlockParserForReplacement(): void
|
|
|
307 |
{
|
|
|
308 |
// Note that we don't want to parse inlines or finalize this block, as it's getting replaced.
|
|
|
309 |
$old = $this->deactivateBlockParser();
|
|
|
310 |
|
|
|
311 |
if ($old instanceof ParagraphParser) {
|
|
|
312 |
$this->updateReferenceMap($old->getReferences());
|
|
|
313 |
}
|
|
|
314 |
|
|
|
315 |
$old->getBlock()->detach();
|
|
|
316 |
}
|
|
|
317 |
|
|
|
318 |
/**
|
|
|
319 |
* @param ReferenceInterface[] $references
|
|
|
320 |
*/
|
|
|
321 |
private function updateReferenceMap(iterable $references): void
|
|
|
322 |
{
|
|
|
323 |
foreach ($references as $reference) {
|
|
|
324 |
if (! $this->referenceMap->contains($reference->getLabel())) {
|
|
|
325 |
$this->referenceMap->add($reference);
|
|
|
326 |
}
|
|
|
327 |
}
|
|
|
328 |
}
|
|
|
329 |
|
|
|
330 |
public function getActiveBlockParser(): BlockContinueParserInterface
|
|
|
331 |
{
|
|
|
332 |
$active = \end($this->activeBlockParsers);
|
|
|
333 |
if ($active === false) {
|
|
|
334 |
throw new \RuntimeException('No active block parsers are available');
|
|
|
335 |
}
|
|
|
336 |
|
|
|
337 |
return $active;
|
|
|
338 |
}
|
|
|
339 |
}
|