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\Block\Parser;
16
17use League\CommonMark\Block\Element\ListBlock;
18use League\CommonMark\Block\Element\ListData;
19use League\CommonMark\Block\Element\ListItem;
20use League\CommonMark\Block\Element\Paragraph;
21use League\CommonMark\ContextInterface;
22use League\CommonMark\Cursor;
23use League\CommonMark\Util\ConfigurationAwareInterface;
24use League\CommonMark\Util\ConfigurationInterface;
25use League\CommonMark\Util\RegexHelper;
26
27final class ListParser implements BlockParserInterface, ConfigurationAwareInterface
28{
29    /** @var ConfigurationInterface|null */
30    private $config;
31
32    /** @var string|null */
33    private $listMarkerRegex;
34
35    public function setConfiguration(ConfigurationInterface $configuration)
36    {
37        $this->config = $configuration;
38    }
39
40    public function parse(ContextInterface $context, Cursor $cursor): bool
41    {
42        if ($cursor->isIndented() && !($context->getContainer() instanceof ListBlock)) {
43            return false;
44        }
45
46        $indent = $cursor->getIndent();
47        if ($indent >= 4) {
48            return false;
49        }
50
51        $tmpCursor = clone $cursor;
52        $tmpCursor->advanceToNextNonSpaceOrTab();
53        $rest = $tmpCursor->getRemainder();
54
55        if (\preg_match($this->listMarkerRegex ?? $this->generateListMarkerRegex(), $rest) === 1) {
56            $data = new ListData();
57            $data->markerOffset = $indent;
58            $data->type = ListBlock::TYPE_BULLET;
59            $data->delimiter = null;
60            $data->bulletChar = $rest[0];
61            $markerLength = 1;
62        } elseif (($matches = RegexHelper::matchFirst('/^(\d{1,9})([.)])/', $rest)) && (!($context->getContainer() instanceof Paragraph) || $matches[1] === '1')) {
63            $data = new ListData();
64            $data->markerOffset = $indent;
65            $data->type = ListBlock::TYPE_ORDERED;
66            $data->start = (int) $matches[1];
67            $data->delimiter = $matches[2];
68            $data->bulletChar = null;
69            $markerLength = \strlen($matches[0]);
70        } else {
71            return false;
72        }
73
74        // Make sure we have spaces after
75        $nextChar = $tmpCursor->peek($markerLength);
76        if (!($nextChar === null || $nextChar === "\t" || $nextChar === ' ')) {
77            return false;
78        }
79
80        // If it interrupts paragraph, make sure first line isn't blank
81        $container = $context->getContainer();
82        if ($container instanceof Paragraph && !RegexHelper::matchAt(RegexHelper::REGEX_NON_SPACE, $rest, $markerLength)) {
83            return false;
84        }
85
86        // We've got a match! Advance offset and calculate padding
87        $cursor->advanceToNextNonSpaceOrTab(); // to start of marker
88        $cursor->advanceBy($markerLength, true); // to end of marker
89        $data->padding = $this->calculateListMarkerPadding($cursor, $markerLength);
90
91        // add the list if needed
92        if (!($container instanceof ListBlock) || !$data->equals($container->getListData())) {
93            $context->addBlock(new ListBlock($data));
94        }
95
96        // add the list item
97        $context->addBlock(new ListItem($data));
98
99        return true;
100    }
101
102    /**
103     * @param Cursor $cursor
104     * @param int    $markerLength
105     *
106     * @return int
107     */
108    private function calculateListMarkerPadding(Cursor $cursor, int $markerLength): int
109    {
110        $start = $cursor->saveState();
111        $spacesStartCol = $cursor->getColumn();
112
113        while ($cursor->getColumn() - $spacesStartCol < 5) {
114            if (!$cursor->advanceBySpaceOrTab()) {
115                break;
116            }
117        }
118
119        $blankItem = $cursor->peek() === null;
120        $spacesAfterMarker = $cursor->getColumn() - $spacesStartCol;
121
122        if ($spacesAfterMarker >= 5 || $spacesAfterMarker < 1 || $blankItem) {
123            $cursor->restoreState($start);
124            $cursor->advanceBySpaceOrTab();
125
126            return $markerLength + 1;
127        }
128
129        return $markerLength + $spacesAfterMarker;
130    }
131
132    private function generateListMarkerRegex(): string
133    {
134        // No configuration given - use the defaults
135        if ($this->config === null) {
136            return $this->listMarkerRegex = '/^[*+-]/';
137        }
138
139        $deprecatedMarkers = $this->config->get('unordered_list_markers', ConfigurationInterface::MISSING);
140        if ($deprecatedMarkers !== ConfigurationInterface::MISSING) {
141            @\trigger_error('The "unordered_list_markers" configuration option is deprecated in league/commonmark 1.6 and will be replaced with "commonmark > unordered_list_markers" in 2.0', \E_USER_DEPRECATED);
142        } else {
143            $deprecatedMarkers = ['*', '+', '-'];
144        }
145
146        $markers = $this->config->get('commonmark/unordered_list_markers', $deprecatedMarkers);
147
148        if (!\is_array($markers) || $markers === []) {
149            throw new \RuntimeException('Invalid configuration option "unordered_list_markers": value must be an array of strings');
150        }
151
152        return $this->listMarkerRegex = '/^[' . \preg_quote(\implode('', $markers), '/') . ']/';
153    }
154}
155