1<?php 2 3declare(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 emphasis processing code based on commonmark-java (https://github.com/atlassian/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 20namespace League\CommonMark\Delimiter; 21 22use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection; 23use League\CommonMark\Node\Inline\AdjacentTextMerger; 24 25final class DelimiterStack 26{ 27 /** @psalm-readonly-allow-private-mutation */ 28 private ?DelimiterInterface $top = null; 29 30 public function push(DelimiterInterface $newDelimiter): void 31 { 32 $newDelimiter->setPrevious($this->top); 33 34 if ($this->top !== null) { 35 $this->top->setNext($newDelimiter); 36 } 37 38 $this->top = $newDelimiter; 39 } 40 41 private function findEarliest(?DelimiterInterface $stackBottom = null): ?DelimiterInterface 42 { 43 $delimiter = $this->top; 44 while ($delimiter !== null && $delimiter->getPrevious() !== $stackBottom) { 45 $delimiter = $delimiter->getPrevious(); 46 } 47 48 return $delimiter; 49 } 50 51 public function removeDelimiter(DelimiterInterface $delimiter): void 52 { 53 if ($delimiter->getPrevious() !== null) { 54 /** @psalm-suppress PossiblyNullReference */ 55 $delimiter->getPrevious()->setNext($delimiter->getNext()); 56 } 57 58 if ($delimiter->getNext() === null) { 59 // top of stack 60 $this->top = $delimiter->getPrevious(); 61 } else { 62 /** @psalm-suppress PossiblyNullReference */ 63 $delimiter->getNext()->setPrevious($delimiter->getPrevious()); 64 } 65 } 66 67 private function removeDelimiterAndNode(DelimiterInterface $delimiter): void 68 { 69 $delimiter->getInlineNode()->detach(); 70 $this->removeDelimiter($delimiter); 71 } 72 73 private function removeDelimitersBetween(DelimiterInterface $opener, DelimiterInterface $closer): void 74 { 75 $delimiter = $closer->getPrevious(); 76 while ($delimiter !== null && $delimiter !== $opener) { 77 $previous = $delimiter->getPrevious(); 78 $this->removeDelimiter($delimiter); 79 $delimiter = $previous; 80 } 81 } 82 83 public function removeAll(?DelimiterInterface $stackBottom = null): void 84 { 85 while ($this->top && $this->top !== $stackBottom) { 86 $this->removeDelimiter($this->top); 87 } 88 } 89 90 public function removeEarlierMatches(string $character): void 91 { 92 $opener = $this->top; 93 while ($opener !== null) { 94 if ($opener->getChar() === $character) { 95 $opener->setActive(false); 96 } 97 98 $opener = $opener->getPrevious(); 99 } 100 } 101 102 /** 103 * @param string|string[] $characters 104 */ 105 public function searchByCharacter($characters): ?DelimiterInterface 106 { 107 if (! \is_array($characters)) { 108 $characters = [$characters]; 109 } 110 111 $opener = $this->top; 112 while ($opener !== null) { 113 if (\in_array($opener->getChar(), $characters, true)) { 114 break; 115 } 116 117 $opener = $opener->getPrevious(); 118 } 119 120 return $opener; 121 } 122 123 public function processDelimiters(?DelimiterInterface $stackBottom, DelimiterProcessorCollection $processors): void 124 { 125 $openersBottom = []; 126 127 // Find first closer above stackBottom 128 $closer = $this->findEarliest($stackBottom); 129 130 // Move forward, looking for closers, and handling each 131 while ($closer !== null) { 132 $delimiterChar = $closer->getChar(); 133 134 $delimiterProcessor = $processors->getDelimiterProcessor($delimiterChar); 135 if (! $closer->canClose() || $delimiterProcessor === null) { 136 $closer = $closer->getNext(); 137 continue; 138 } 139 140 $openingDelimiterChar = $delimiterProcessor->getOpeningCharacter(); 141 142 $useDelims = 0; 143 $openerFound = false; 144 $potentialOpenerFound = false; 145 $opener = $closer->getPrevious(); 146 while ($opener !== null && $opener !== $stackBottom && $opener !== ($openersBottom[$delimiterChar] ?? null)) { 147 if ($opener->canOpen() && $opener->getChar() === $openingDelimiterChar) { 148 $potentialOpenerFound = true; 149 $useDelims = $delimiterProcessor->getDelimiterUse($opener, $closer); 150 if ($useDelims > 0) { 151 $openerFound = true; 152 break; 153 } 154 } 155 156 $opener = $opener->getPrevious(); 157 } 158 159 if (! $openerFound) { 160 if (! $potentialOpenerFound) { 161 // Only do this when we didn't even have a potential 162 // opener (one that matches the character and can open). 163 // If an opener was rejected because of the number of 164 // delimiters (e.g. because of the "multiple of 3" 165 // Set lower bound for future searches for openersrule), 166 // we want to consider it next time because the number 167 // of delimiters can change as we continue processing. 168 $openersBottom[$delimiterChar] = $closer->getPrevious(); 169 if (! $closer->canOpen()) { 170 // We can remove a closer that can't be an opener, 171 // once we've seen there's no matching opener. 172 $this->removeDelimiter($closer); 173 } 174 } 175 176 $closer = $closer->getNext(); 177 continue; 178 } 179 180 \assert($opener !== null); 181 182 $openerNode = $opener->getInlineNode(); 183 $closerNode = $closer->getInlineNode(); 184 185 // Remove number of used delimiters from stack and inline nodes. 186 $opener->setLength($opener->getLength() - $useDelims); 187 $closer->setLength($closer->getLength() - $useDelims); 188 189 $openerNode->setLiteral(\substr($openerNode->getLiteral(), 0, -$useDelims)); 190 $closerNode->setLiteral(\substr($closerNode->getLiteral(), 0, -$useDelims)); 191 192 $this->removeDelimitersBetween($opener, $closer); 193 // The delimiter processor can re-parent the nodes between opener and closer, 194 // so make sure they're contiguous already. Exclusive because we want to keep opener/closer themselves. 195 AdjacentTextMerger::mergeTextNodesBetweenExclusive($openerNode, $closerNode); 196 $delimiterProcessor->process($openerNode, $closerNode, $useDelims); 197 198 // No delimiter characters left to process, so we can remove delimiter and the now empty node. 199 if ($opener->getLength() === 0) { 200 $this->removeDelimiterAndNode($opener); 201 } 202 203 // phpcs:disable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed 204 if ($closer->getLength() === 0) { 205 $next = $closer->getNext(); 206 $this->removeDelimiterAndNode($closer); 207 $closer = $next; 208 } 209 } 210 211 // Remove all delimiters 212 $this->removeAll($stackBottom); 213 } 214} 215