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\Block\Element\Document;
19use League\CommonMark\Block\Element\Paragraph;
20use League\CommonMark\Block\Parser\BlockParserInterface;
21use League\CommonMark\Context;
22use League\CommonMark\ContextInterface;
23use League\CommonMark\Cursor;
24use League\CommonMark\EnvironmentAwareInterface;
25use League\CommonMark\EnvironmentInterface;
26
27final class TableParser implements BlockParserInterface, EnvironmentAwareInterface
28{
29    /**
30     * @var EnvironmentInterface
31     */
32    private $environment;
33
34    public function parse(ContextInterface $context, Cursor $cursor): bool
35    {
36        $container = $context->getContainer();
37        if (!$container instanceof Paragraph) {
38            return false;
39        }
40
41        $lines = $container->getStrings();
42        if (count($lines) === 0) {
43            return false;
44        }
45
46        $lastLine = \array_pop($lines);
47        if (\strpos($lastLine, '|') === false) {
48            return false;
49        }
50
51        $oldState = $cursor->saveState();
52        $cursor->advanceToNextNonSpaceOrTab();
53        $columns = $this->parseColumns($cursor);
54
55        if (empty($columns)) {
56            $cursor->restoreState($oldState);
57
58            return false;
59        }
60
61        $head = $this->parseRow(trim((string) $lastLine), $columns, TableCell::TYPE_HEAD);
62        if (null === $head) {
63            $cursor->restoreState($oldState);
64
65            return false;
66        }
67
68        $table = new Table(function (Cursor $cursor, Table $table) use ($columns): bool {
69            // The next line cannot be a new block start
70            // This is a bit inefficient, but it's the only feasible way to check
71            // given the current v1 API.
72            if (self::isANewBlock($this->environment, $cursor->getLine())) {
73                return false;
74            }
75
76            $row = $this->parseRow(\trim($cursor->getLine()), $columns);
77            if (null === $row) {
78                return false;
79            }
80
81            $table->getBody()->appendChild($row);
82
83            return true;
84        });
85
86        $table->getHead()->appendChild($head);
87
88        if (count($lines) >= 1) {
89            $paragraph = new Paragraph();
90            foreach ($lines as $line) {
91                $paragraph->addLine($line);
92            }
93
94            $context->replaceContainerBlock($paragraph);
95            $context->addBlock($table);
96        } else {
97            $context->replaceContainerBlock($table);
98        }
99
100        return true;
101    }
102
103    /**
104     * @param string             $line
105     * @param array<int, string> $columns
106     * @param string             $type
107     *
108     * @return TableRow|null
109     */
110    private function parseRow(string $line, array $columns, string $type = TableCell::TYPE_BODY): ?TableRow
111    {
112        $cells = $this->split(new Cursor(\trim($line)));
113
114        if (empty($cells)) {
115            return null;
116        }
117
118        // The header row must match the delimiter row in the number of cells
119        if ($type === TableCell::TYPE_HEAD && \count($cells) !== \count($columns)) {
120            return null;
121        }
122
123        $i = 0;
124        $row = new TableRow();
125        foreach ($cells as $i => $cell) {
126            if (!array_key_exists($i, $columns)) {
127                return $row;
128            }
129
130            $row->appendChild(new TableCell(trim($cell), $type, $columns[$i]));
131        }
132
133        for ($j = count($columns) - 1; $j > $i; --$j) {
134            $row->appendChild(new TableCell('', $type, null));
135        }
136
137        return $row;
138    }
139
140    /**
141     * @param Cursor $cursor
142     *
143     * @return array<int, string>
144     */
145    private function split(Cursor $cursor): array
146    {
147        if ($cursor->getCharacter() === '|') {
148            $cursor->advanceBy(1);
149        }
150
151        $cells = [];
152        $sb = '';
153
154        while (!$cursor->isAtEnd()) {
155            switch ($c = $cursor->getCharacter()) {
156                case '\\':
157                    if ($cursor->peek() === '|') {
158                        // Pipe is special for table parsing. An escaped pipe doesn't result in a new cell, but is
159                        // passed down to inline parsing as an unescaped pipe. Note that that applies even for the `\|`
160                        // in an input like `\\|` - in other words, table parsing doesn't support escaping backslashes.
161                        $sb .= '|';
162                        $cursor->advanceBy(1);
163                    } else {
164                        // Preserve backslash before other characters or at end of line.
165                        $sb .= '\\';
166                    }
167                    break;
168                case '|':
169                    $cells[] = $sb;
170                    $sb = '';
171                    break;
172                default:
173                    $sb .= $c;
174            }
175            $cursor->advanceBy(1);
176        }
177
178        if ($sb !== '') {
179            $cells[] = $sb;
180        }
181
182        return $cells;
183    }
184
185    /**
186     * @param Cursor $cursor
187     *
188     * @return array<int, string>
189     */
190    private function parseColumns(Cursor $cursor): array
191    {
192        $columns = [];
193        $pipes = 0;
194        $valid = false;
195
196        while (!$cursor->isAtEnd()) {
197            switch ($c = $cursor->getCharacter()) {
198                case '|':
199                    $cursor->advanceBy(1);
200                    $pipes++;
201                    if ($pipes > 1) {
202                        // More than one adjacent pipe not allowed
203                        return [];
204                    }
205
206                    // Need at least one pipe, even for a one-column table
207                    $valid = true;
208                    break;
209                case '-':
210                case ':':
211                    if ($pipes === 0 && !empty($columns)) {
212                        // Need a pipe after the first column (first column doesn't need to start with one)
213                        return [];
214                    }
215                    $left = false;
216                    $right = false;
217                    if ($c === ':') {
218                        $left = true;
219                        $cursor->advanceBy(1);
220                    }
221                    if ($cursor->match('/^-+/') === null) {
222                        // Need at least one dash
223                        return [];
224                    }
225                    if ($cursor->getCharacter() === ':') {
226                        $right = true;
227                        $cursor->advanceBy(1);
228                    }
229                    $columns[] = $this->getAlignment($left, $right);
230                    // Next, need another pipe
231                    $pipes = 0;
232                    break;
233                case ' ':
234                case "\t":
235                    // White space is allowed between pipes and columns
236                    $cursor->advanceToNextNonSpaceOrTab();
237                    break;
238                default:
239                    // Any other character is invalid
240                    return [];
241            }
242        }
243
244        if (!$valid) {
245            return [];
246        }
247
248        return $columns;
249    }
250
251    private static function getAlignment(bool $left, bool $right): ?string
252    {
253        if ($left && $right) {
254            return TableCell::ALIGN_CENTER;
255        } elseif ($left) {
256            return TableCell::ALIGN_LEFT;
257        } elseif ($right) {
258            return TableCell::ALIGN_RIGHT;
259        }
260
261        return null;
262    }
263
264    public function setEnvironment(EnvironmentInterface $environment)
265    {
266        $this->environment = $environment;
267    }
268
269    private static function isANewBlock(EnvironmentInterface $environment, string $line): bool
270    {
271        $context = new Context(new Document(), $environment);
272        $context->setNextLine($line);
273        $cursor = new Cursor($line);
274
275        /** @var BlockParserInterface $parser */
276        foreach ($environment->getBlockParsers() as $parser) {
277            if ($parser->parse($context, $cursor)) {
278                return true;
279            }
280        }
281
282        return false;
283    }
284}
285