Completed
Push — gfm-extensions ( 7d034f...5db7c9 )
by Colin
124:03 queued 88:54
created

TableParser::parseCaption()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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