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