• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / texy / 30792106735

03 Aug 2026 06:40AM UTC coverage: 94.771% (+0.09%) from 94.677%
30792106735

push

github

dg
Engine: the text decoder is injectable

InlineParser hardwired Helpers::decodeEntities() into every TextNode it
created, which put a content policy inside the parsing mechanics: what
counts as display text belongs to the language, not to the engine. It is
now a Closure(string): string taken by the Engine constructor, with the
old behaviour as the default, so Texy is unaffected and a language where
"&" is not an entity can pass fn($s) => $s. A custom decoder takes
over the control-character sanitization the default performs.

7 of 7 new or added lines in 2 files covered. (100.0%)

99 existing lines in 18 files now uncovered.

3498 of 3691 relevant lines covered (94.77%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.29
/src/Texy/Modules/TableModule.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\Modifier;
12
use Texy\Nodes\ContentNode;
13
use Texy\Nodes\TableCellNode;
14
use Texy\Nodes\TableNode;
15
use Texy\Nodes\TableRowNode;
16
use Texy\Parsing\ParseContext;
17
use Texy\Patterns;
18
use Texy\Range;
19
use Texy\Regexp;
20
use Texy\Syntax;
21
use function array_pop, count, ltrim, rtrim, strlen;
22

23

24
/**
25
 * Table module.
26
 */
27
final class TableModule extends Texy\Module
28
{
29
        private bool $disableTables = false;
30

31

32
        public function __construct(
1✔
33
                private Texy\Texy $texy,
34
        ) {
35
        }
1✔
36

37

38
        public function beforeParse(): void
39
        {
40
                $this->texy->addBlockSyntax(
1✔
41
                        Syntax::Table,
1✔
42
                        '~^
43
                                (?:' . Patterns::ModifierHVAlign . '\n)? # modifier (1)
1✔
44
                                \|                                   # table start
45
                                .*                                   # content
46
                        $~mUx',
47
                        $this->parseTable(...),
1✔
48
                );
49
        }
1✔
50

51

52
        /**
53
         * Parses tables.
54
         * @param  array{string, ?string}  $matches
55
         * @param  array{int, ?int}  $offsets
56
         */
57
        public function parseTable(ParseContext $context, array $matches, array $offsets): ?TableNode
1✔
58
        {
59
                if ($this->disableTables) {
1✔
60
                        return null;
1✔
61
                }
62

63
                [, $mMod] = $matches;
1✔
64

65
                $startOffset = $offsets[0];
1✔
66

67
                $context->getBlockParser()->moveBackward();
1✔
68

69
                $caption = $this->parseCaption($context);
1✔
70

71
                $rows = [];
1✔
72
                $isHead = false;
1✔
73
                /** @var array<int, array{node: TableCellNode, text: string}> $prevRow */
74
                $prevRow = [];
1✔
75
                $colModifier = [];
1✔
76
                $colCounter = 0;
1✔
77
                /** @var \SplObjectStorage<TableCellNode, list<array{content: string, offset: int}>> $cellTexts */
78
                $cellTexts = new \SplObjectStorage;
1✔
79

80
                while (true) {
1✔
81
                        $lineMatches = null;
1✔
82
                        $lineOffsets = null;
1✔
83
                        if ($context->getBlockParser()->next('~^ \| ([=-]) [+|=-]{2,} $~Umx', $lineMatches, $lineOffsets)) {
1✔
84
                                $isHead = !$isHead;
1✔
85
                                $prevRow = [];
1✔
86
                                continue;
1✔
87
                        }
88

89
                        if ($context->getBlockParser()->next('~^ ( \| ) (.*) (?: | \| [ \t]* ' . Patterns::ModifierHVAlign . '?)$~Ux', $lineMatches, $lineOffsets)) {
1✔
90
                                // smarter head detection: if first row is followed by separator line, it's a head row
91
                                if (count($rows) === 0 && !$isHead && $context->getBlockParser()->next('~^ \| [=-] [+|=-]{2,} $~Umx', $foo)) {
1✔
92
                                        $isHead = true;
1✔
93
                                        $context->getBlockParser()->moveBackward();
1✔
94
                                }
95

96
                                // groups 1 and 2 always participate in a successful match, but next() cannot type that
97
                                $mContent = $lineMatches[2] ?? throw new \LogicException('Match without group 2.');
1✔
98
                                $mRowMod = $lineMatches[3] ?? null;
1✔
99
                                $lineBaseOffset = $lineOffsets[2] ?? throw new \LogicException('Match without group 2.'); // content after first |
1✔
100

101
                                $cells = [];
1✔
102
                                $originalContent = $mContent;
1✔
103
                                $content = str_replace('\|', "\x13", $mContent);
1✔
104
                                $content = Regexp::replace($content, '~(\[[^]]*)\|~', "$1\x13");
1✔
105

106
                                $col = 0;
1✔
107
                                $lastCell = null;
1✔
108
                                $cellOffset = 0; // position within $content
1✔
109

110
                                foreach (explode('|', $content) as $cellIndex => $cell) {
1✔
111
                                        $originalCell = $cell;
1✔
112
                                        $cell = strtr($cell, "\x13", '|');
1✔
113
                                        $cellAbsoluteOffset = $lineBaseOffset + $cellOffset;
1✔
114

115
                                        // rowSpan: ^ at end of cell or cell is just ^
116
                                        if (isset($prevRow[$col]) && ($m = Regexp::match($cell, '~\^[ \t]*$|\*??(.*)[ \t]+\^$~AU', captureOffset: true))) {
1✔
117
                                                $prevRow[$col]['node']->rowspan++;
1✔
118
                                                /** @var non-empty-array<array{?string, int}> $m */
119
                                                $cellText = $m[1][0] ?? '';
1✔
120
                                                $cellTextOffset = ($m[1][1] ?? -1) >= 0 ? $cellAbsoluteOffset + $m[1][1] : $cellAbsoluteOffset;
1✔
121
                                                // Append text to the cell above
122
                                                $lines = $cellTexts[$prevRow[$col]['node']];
1✔
123
                                                $lines[] = ['content' => (string) $cellText, 'offset' => $cellTextOffset];
1✔
124
                                                $cellTexts[$prevRow[$col]['node']] = $lines;
1✔
125
                                                $col += $prevRow[$col]['node']->colspan;
1✔
126
                                                $lastCell = null;
1✔
127
                                                $cellOffset += strlen($originalCell) + 1; // +1 for |
1✔
128
                                                continue;
1✔
129
                                        }
130

131
                                        // colSpan: empty cell extends previous cell
132
                                        if ($cell === '' && $lastCell !== null) {
1✔
133
                                                $lastCell->colspan++;
1✔
134
                                                unset($prevRow[$col]);
1✔
135
                                                $col++;
1✔
136
                                                $cellOffset += strlen($originalCell) + 1;
1✔
137
                                                continue;
1✔
138
                                        }
139

140
                                        // common cell
141
                                        $cellMatches = Regexp::match($cell, '~
1✔
142
                                                ( \*?? )                          # head mark (1)
143
                                                [ \t]*
144
                                                ' . Patterns::ModifierHVAlign . '??   # modifier (2)
145
                                                (.*)                              # content (3)
146
                                                ' . Patterns::ModifierHVAlign . '?    # modifier (4)
1✔
147
                                                [ \t]*
148
                                        $~AUx', captureOffset: true);
1✔
149

150
                                        if ($cellMatches) {
1✔
151
                                                $mHead = $cellMatches[1][0];
1✔
152
                                                $mModCol = $cellMatches[2][0];
1✔
153
                                                // group 3 is (.*), so it always participates
154
                                                $mCellContent = $cellMatches[3][0] ?? throw new \LogicException('Cell without group 3.');
1✔
155
                                                $mCellContentOffset = $cellMatches[3][1];
1✔
156
                                                $mCellMod = $cellMatches[4][0];
1✔
157

158
                                                $cellIsHeader = $isHead || ($mHead === '*');
1✔
159

160
                                                // column modifier inheritance
161
                                                if ($mModCol) {
1✔
UNCOV
162
                                                        $colModifier[$col] = Modifier::parse($mModCol, $cellMatches[2][1] >= 0 ? $cellAbsoluteOffset + $cellMatches[2][1] : null);
×
163
                                                }
164
                                                $cellMod = isset($colModifier[$col]) ? clone $colModifier[$col] : new Modifier;
1✔
165
                                                $cellMod->setProperties($mCellMod);
1✔
166

167
                                                // Calculate absolute offset of cell content
168
                                                $contentAbsoluteOffset = $cellAbsoluteOffset + $mCellContentOffset;
1✔
169

170
                                                // Create cell node - text will be parsed later
171
                                                $lastCell = new TableCellNode(new ContentNode, 1, 1, $cellIsHeader, $cellMod, new Range($cellAbsoluteOffset, strlen($originalCell)));
1✔
172
                                                $cells[] = $lastCell;
1✔
173
                                                $cellTexts[$lastCell] = [['content' => $mCellContent, 'offset' => $contentAbsoluteOffset]];
1✔
174
                                                $prevRow[$col] = ['node' => $lastCell, 'text' => $mCellContent];
1✔
175
                                                $col++;
1✔
176
                                        }
177

178
                                        $cellOffset += strlen($originalCell) + 1; // +1 for |
1✔
179
                                }
180

181
                                // even up with empty cells
182
                                while ($col < $colCounter) {
1✔
183
                                        $cellMod = isset($colModifier[$col]) ? clone $colModifier[$col] : new Modifier;
1✔
184
                                        $emptyCell = new TableCellNode(new ContentNode, 1, 1, $isHead, $cellMod);
1✔
185
                                        $cells[] = $emptyCell;
1✔
186
                                        $cellTexts[$emptyCell] = [['content' => '', 'offset' => 0]];
1✔
187
                                        $prevRow[$col] = ['node' => $emptyCell, 'text' => ''];
1✔
188
                                        $col++;
1✔
189
                                }
190

191
                                $colCounter = $col;
1✔
192

193
                                if ($cells) {
1✔
194
                                        $rowMod = Modifier::parse($mRowMod, $lineOffsets[3] ?? null);
1✔
195
                                        // group 0 always participates in a successful match, but next() cannot type that
196
                                        $rowOffset = $lineOffsets[0] ?? throw new \LogicException('Match without group 0.');
1✔
197
                                        $rowText = $lineMatches[0] ?? throw new \LogicException('Match without group 0.');
1✔
198
                                        $rows[] = new TableRowNode($cells, $isHead, $rowMod, new Range($rowOffset, strlen($rowText)));
1✔
199
                                } else {
200
                                        // redundant row - decrement rowspan
201
                                        foreach ($prevRow as $item) {
1✔
202
                                                $item['node']->rowspan--;
1✔
203
                                        }
204
                                }
205

206
                                continue;
1✔
207
                        }
208

209
                        break;
1✔
210
                }
211

212
                if (!$rows) {
1✔
213
                        return null;
1✔
214
                }
215

216
                // Parse cell text content after rowspan/colspan is determined
217
                foreach ($rows as $row) {
1✔
218
                        foreach ($row->cells as $cell) {
1✔
219
                                if (isset($cellTexts[$cell])) {
1✔
220
                                        $lines = $cellTexts[$cell];
1✔
221

222
                                        // right-trim: drop blank trailing fragments, trim the last one
223
                                        while ($lines && rtrim($lines[count($lines) - 1]['content']) === '') {
1✔
224
                                                array_pop($lines);
1✔
225
                                        }
226
                                        if ($lines) {
1✔
227
                                                $last = count($lines) - 1;
1✔
228
                                                $lines[$last]['content'] = rtrim($lines[$last]['content']);
1✔
229
                                        }
230

231
                                        if (count($lines) > 1) {
1✔
232
                                                // multiline - parse as block (disable nested tables)
233
                                                [$outdented, $map] = Texy\Parsing\OffsetMap::outdentLines($lines);
1✔
234
                                                $this->disableTables = true;
1✔
235
                                                try {
236
                                                        $parsed = $context->parseBlock($outdented);
1✔
237
                                                } finally {
1✔
238
                                                        $this->disableTables = false;
1✔
239
                                                }
240
                                                $map->applyTo($parsed);
1✔
241
                                                $cell->content->children = $parsed->children;
1✔
242
                                        } else {
243
                                                // single line - parse as inline
244
                                                $text = $lines[0]['content'] ?? '';
1✔
245
                                                $trimmed = ltrim($text);
1✔
246
                                                $trimOffset = strlen($text) - strlen($trimmed);
1✔
247
                                                $baseOffset = ($lines[0]['offset'] ?? 0) + $trimOffset;
1✔
248
                                                $cell->content->children = $context->parseInline($trimmed, $baseOffset)->children;
1✔
249
                                        }
250

251
                                        // empty cell gets &nbsp;
252
                                        if ($cell->content->children === []) {
1✔
253
                                                $cell->content->children = [new Texy\Nodes\TextNode("\u{A0}")];
1✔
254
                                        }
255
                                }
256
                        }
257
                }
258

259
                return new TableNode(
1✔
260
                        $rows,
1✔
261
                        Modifier::parse($mMod, $offsets[1]),
1✔
262
                        new Range($startOffset, strlen($matches[0])),
1✔
263
                        $caption,
264
                );
265
        }
266

267

268
        /** Parses the optional caption line |## Text preceding the first row. */
269
        private function parseCaption(ParseContext $context): ?ContentNode
1✔
270
        {
271
                $matches = $offsets = null;
1✔
272
                $context->getBlockParser()->next(
1✔
273
                        '~^
1✔
274
                                \|                            # opening pipe
275
                                ( [\#=] ){2,}  (?! [|\#=+] )  # opening chars (1)
276
                                (.+)                          # caption (2)
277
                                \1*                           # matching closing chars
278
                                \|? [ \t]*                    # optional closing pipe and spaces
279
                        $~Umx',
280
                        $matches,
281
                        $offsets,
282
                );
283

284
                if (!isset($matches[2], $offsets[2])) {
1✔
285
                        return null;
1✔
286
                }
287

288
                // space after the opening chars is a separator, not content
289
                $text = rtrim($matches[2]);
1✔
290
                $trimmed = ltrim($text);
1✔
291
                $offset = $offsets[2] + strlen($text) - strlen($trimmed);
1✔
292

293
                return $context->parseInline($trimmed, $offset);
1✔
294
        }
295
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc