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