1<?php 2 3declare(strict_types=1); 4 5/* 6 * This is part of the league/commonmark package. 7 * 8 * (c) Martin Hasoň <martin.hason@gmail.com> 9 * (c) Webuni s.r.o. <info@webuni.cz> 10 * (c) Colin O'Dell <colinodell@gmail.com> 11 * 12 * For the full copyright and license information, please view the LICENSE 13 * file that was distributed with this source code. 14 */ 15 16namespace League\CommonMark\Extension\Table; 17 18use League\CommonMark\Parser\Block\AbstractBlockContinueParser; 19use League\CommonMark\Parser\Block\BlockContinue; 20use League\CommonMark\Parser\Block\BlockContinueParserInterface; 21use League\CommonMark\Parser\Block\BlockContinueParserWithInlinesInterface; 22use League\CommonMark\Parser\Cursor; 23use League\CommonMark\Parser\InlineParserEngineInterface; 24use League\CommonMark\Util\ArrayCollection; 25 26final class TableParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface 27{ 28 /** @psalm-readonly */ 29 private Table $block; 30 31 /** 32 * @var ArrayCollection<string> 33 * 34 * @psalm-readonly-allow-private-mutation 35 */ 36 private ArrayCollection $bodyLines; 37 38 /** 39 * @var array<int, string|null> 40 * @psalm-var array<int, TableCell::ALIGN_*|null> 41 * @phpstan-var array<int, TableCell::ALIGN_*|null> 42 * 43 * @psalm-readonly 44 */ 45 private array $columns; 46 47 /** 48 * @var array<int, string> 49 * 50 * @psalm-readonly-allow-private-mutation 51 */ 52 private array $headerCells; 53 54 /** @psalm-readonly-allow-private-mutation */ 55 private bool $nextIsSeparatorLine = true; 56 57 /** 58 * @param array<int, string|null> $columns 59 * @param array<int, string> $headerCells 60 * 61 * @psalm-param array<int, TableCell::ALIGN_*|null> $columns 62 * 63 * @phpstan-param array<int, TableCell::ALIGN_*|null> $columns 64 */ 65 public function __construct(array $columns, array $headerCells) 66 { 67 $this->block = new Table(); 68 $this->bodyLines = new ArrayCollection(); 69 $this->columns = $columns; 70 $this->headerCells = $headerCells; 71 } 72 73 public function canHaveLazyContinuationLines(): bool 74 { 75 return true; 76 } 77 78 public function getBlock(): Table 79 { 80 return $this->block; 81 } 82 83 public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue 84 { 85 if (\strpos($cursor->getLine(), '|') === false) { 86 return BlockContinue::none(); 87 } 88 89 return BlockContinue::at($cursor); 90 } 91 92 public function addLine(string $line): void 93 { 94 if ($this->nextIsSeparatorLine) { 95 $this->nextIsSeparatorLine = false; 96 } else { 97 $this->bodyLines[] = $line; 98 } 99 } 100 101 public function parseInlines(InlineParserEngineInterface $inlineParser): void 102 { 103 $headerColumns = \count($this->headerCells); 104 105 $head = new TableSection(TableSection::TYPE_HEAD); 106 $this->block->appendChild($head); 107 108 $headerRow = new TableRow(); 109 $head->appendChild($headerRow); 110 for ($i = 0; $i < $headerColumns; $i++) { 111 $cell = $this->headerCells[$i]; 112 $tableCell = $this->parseCell($cell, $i, $inlineParser); 113 $tableCell->setType(TableCell::TYPE_HEADER); 114 $headerRow->appendChild($tableCell); 115 } 116 117 $body = null; 118 foreach ($this->bodyLines as $rowLine) { 119 $cells = self::split($rowLine); 120 $row = new TableRow(); 121 122 // Body can not have more columns than head 123 for ($i = 0; $i < $headerColumns; $i++) { 124 $cell = $cells[$i] ?? ''; 125 $tableCell = $this->parseCell($cell, $i, $inlineParser); 126 $row->appendChild($tableCell); 127 } 128 129 if ($body === null) { 130 // It's valid to have a table without body. In that case, don't add an empty TableBody node. 131 $body = new TableSection(); 132 $this->block->appendChild($body); 133 } 134 135 $body->appendChild($row); 136 } 137 } 138 139 private function parseCell(string $cell, int $column, InlineParserEngineInterface $inlineParser): TableCell 140 { 141 $tableCell = new TableCell(); 142 143 if ($column < \count($this->columns)) { 144 $tableCell->setAlign($this->columns[$column]); 145 } 146 147 $inlineParser->parse(\trim($cell), $tableCell); 148 149 return $tableCell; 150 } 151 152 /** 153 * @internal 154 * 155 * @return array<int, string> 156 */ 157 public static function split(string $line): array 158 { 159 $cursor = new Cursor(\trim($line)); 160 161 if ($cursor->getCurrentCharacter() === '|') { 162 $cursor->advanceBy(1); 163 } 164 165 $cells = []; 166 $sb = ''; 167 168 while (! $cursor->isAtEnd()) { 169 switch ($c = $cursor->getCurrentCharacter()) { 170 case '\\': 171 if ($cursor->peek() === '|') { 172 // Pipe is special for table parsing. An escaped pipe doesn't result in a new cell, but is 173 // passed down to inline parsing as an unescaped pipe. Note that that applies even for the `\|` 174 // in an input like `\\|` - in other words, table parsing doesn't support escaping backslashes. 175 $sb .= '|'; 176 $cursor->advanceBy(1); 177 } else { 178 // Preserve backslash before other characters or at end of line. 179 $sb .= '\\'; 180 } 181 182 break; 183 case '|': 184 $cells[] = $sb; 185 $sb = ''; 186 break; 187 default: 188 $sb .= $c; 189 } 190 191 $cursor->advanceBy(1); 192 } 193 194 if ($sb !== '') { 195 $cells[] = $sb; 196 } 197 198 return $cells; 199 } 200} 201