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

dg / texy / 21501721037

30 Jan 2026 02:00AM UTC coverage: 91.159% (-1.3%) from 92.426%
21501721037

push

github

dg
wip

2681 of 2941 relevant lines covered (91.16%)

0.91 hits per line

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

96.35
/src/Texy/Modules/ListModule.php
1
<?php
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
declare(strict_types=1);
9

10
namespace Texy\Modules;
11

12
use Texy;
13
use Texy\Modifier;
14
use Texy\Nodes\DefinitionListNode;
15
use Texy\Nodes\ListItemNode;
16
use Texy\Nodes\ListNode;
17
use Texy\Nodes\ListType;
18
use Texy\ParseContext;
19
use Texy\Patterns;
20
use Texy\Position;
21
use Texy\Regexp;
22
use Texy\Syntax;
23
use function count, implode, ord, strlen;
24

25

26
/**
27
 * Processes ordered, unordered, and definition lists with nesting.
28
 */
29
final class ListModule extends Texy\Module
30
{
31
        /** @var array<string, array{string, ListType, 2?: string}> [regex, type, next-regex?] */
32
        public array $bullets = [
33
                '*' => ['\* [ \t]', ListType::Unordered],
34
                '-' => ['[\x{2013}-] (?! [>-] )', ListType::Unordered],
35
                '+' => ['\+ [ \t]', ListType::Unordered],
36
                '1.' => ['1 \. [ \t]' /* not \d! */, ListType::Decimal, '\d{1,3} \. [ \t]'],
37
                '1)' => ['\d{1,3} \) [ \t]', ListType::Decimal],
38
                'I.' => ['I \. [ \t]', ListType::UpperRoman, '[IVX]{1,4} \. [ \t]'],
39
                'I)' => ['[IVX]+ \) [ \t]', ListType::UpperRoman], // before A) !
40
                'a)' => ['[a-z] \) [ \t]', ListType::LowerAlpha],
41
                'A)' => ['[A-Z] \) [ \t]', ListType::UpperAlpha],
42
        ];
43

44

45
        public function __construct(
1✔
46
                private Texy\Texy $texy,
47
        ) {
48
                $texy->allowed[Syntax::List] = true;
1✔
49
                $texy->allowed[Syntax::DefinitionList] = true;
1✔
50
        }
1✔
51

52

53
        public function beforeParse(string &$text): void
1✔
54
        {
55
                $RE = $REul = [];
1✔
56
                foreach ($this->bullets as $desc) {
1✔
57
                        $RE[] = $desc[0];
1✔
58
                        if (!$desc[1]->isOrdered()) {
1✔
59
                                $REul[] = $desc[0];
1✔
60
                        }
61
                }
62

63
                $this->texy->registerBlockPattern(
1✔
64
                        $this->parseList(...),
1✔
65
                        '~^
66
                                (?:' . Patterns::MODIFIER_H . '\n)? # modifier (1)
1✔
67
                                (' . implode('|', $RE) . ')         # list marker (2)
1✔
68
                                [ \t]*+
69
                                \S .*                               # content
70
                        $~mU',
71
                        Syntax::List,
1✔
72
                );
73

74
                $this->texy->registerBlockPattern(
1✔
75
                        $this->parseDefList(...),
1✔
76
                        '~^
77
                                (?:' . Patterns::MODIFIER_H . '\n)?   # modifier (1)
1✔
78
                                ( \S .{0,2000} )                      # definition term (2)
79
                                : [ \t]*                              # colon separator
80
                                ' . Patterns::MODIFIER_H . '?         # modifier (3)
1✔
81
                                \n
82
                                ([ \t]++)                             # indentation (4)
83
                                (' . implode('|', $REul) . ')         # description marker (5)
1✔
84
                                [ \t]*+
85
                                \S .*                                 # content
86
                        $~mU',
87
                        Syntax::DefinitionList,
1✔
88
                );
89
        }
1✔
90

91

92
        /**
93
         * Parses list.
94
         * @param  array<?string>  $matches
95
         * @param  array<?int>  $offsets
96
         */
97
        public function parseList(ParseContext $context, array $matches, array $offsets): ?ListNode
1✔
98
        {
99
                [, $mMod, $mBullet] = $matches;
1✔
100

101
                $bullet = null;
1✔
102
                $min = 1;
1✔
103
                $start = null;
1✔
104
                $listType = ListType::Unordered;
1✔
105

106
                foreach ($this->bullets as $key => $desc) {
1✔
107
                        if (Regexp::match($mBullet, '~' . $desc[0] . '~A')) {
1✔
108
                                $bullet = $desc[2] ?? $desc[0];
1✔
109
                                $min = isset($desc[2]) ? 2 : 1;
1✔
110
                                $listType = $desc[1];
1✔
111
                                if ($listType->isOrdered()) {
1✔
112
                                        if ($key[0] === '1' && (int) $mBullet > 1) {
1✔
113
                                                $start = (int) $mBullet;
×
114
                                        } elseif ($key[0] === 'a' && $mBullet[0] > 'a') {
1✔
115
                                                $start = ord($mBullet[0]) - 96;
×
116
                                        } elseif ($key[0] === 'A' && $mBullet[0] > 'A') {
1✔
117
                                                $start = ord($mBullet[0]) - 64;
×
118
                                        }
119
                                }
120
                                break;
1✔
121
                        }
122
                }
123

124
                if ($bullet === null) {
1✔
125
                        return null;
×
126
                }
127

128
                $context->getBlockParser()->moveBackward(1);
1✔
129

130
                $items = [];
1✔
131
                while ($item = $this->parseItem($context, $bullet, false)) {
1✔
132
                        $items[] = $item;
1✔
133
                }
134

135
                if (count($items) < $min) {
1✔
136
                        return null;
1✔
137
                }
138

139
                return new ListNode(
1✔
140
                        $items,
1✔
141
                        $listType,
142
                        $start,
143
                        Modifier::parse($mMod),
1✔
144
                        new Position($offsets[0], strlen($matches[0])),
1✔
145
                );
146
        }
147

148

149
        /**
150
         * Parses definition list.
151
         * @param  array<?string>  $matches
152
         * @param  array<?int>  $offsets
153
         */
154
        public function parseDefList(ParseContext $context, array $matches, array $offsets): ?DefinitionListNode
1✔
155
        {
156
                [, $mMod, , , , $mBullet] = $matches;
1✔
157

158
                $bullet = null;
1✔
159

160
                foreach ($this->bullets as $desc) {
1✔
161
                        if (Regexp::match($mBullet, '~' . $desc[0] . '~A')) {
1✔
162
                                $bullet = $desc[2] ?? $desc[0];
1✔
163
                                break;
1✔
164
                        }
165
                }
166

167
                if ($bullet === null) {
1✔
168
                        return null;
×
169
                }
170

171
                $context->getBlockParser()->moveBackward(2);
1✔
172

173
                $items = [];
1✔
174
                $patternTerm = '~^
1✔
175
                        \n?
176
                        ( \S .* )                       # term content
177
                        : [ \t]*                        # colon separator
178
                        ' . Patterns::MODIFIER_H . '?
1✔
179
                $~mUA';
180

181
                while (true) {
1✔
182
                        if ($item = $this->parseItem($context, $bullet, true)) {
1✔
183
                                $items[] = $item;
1✔
184
                                continue;
1✔
185
                        }
186

187
                        $termMatches = null;
1✔
188
                        $termOffsets = null;
1✔
189
                        if ($context->getBlockParser()->next($patternTerm, $termMatches, $termOffsets)) {
1✔
190
                                [, $mContent, $mTermMod] = $termMatches;
1✔
191
                                $termMod = Modifier::parse($mTermMod);
1✔
192
                                $contentOffset = $termOffsets[1] ?? 0;
1✔
193
                                $termContent = $context->parseInline($mContent, $contentOffset);
1✔
194
                                $items[] = new ListItemNode($termContent, true, $termMod);
1✔
195
                                continue;
1✔
196
                        }
197

198
                        break;
1✔
199
                }
200

201
                return new DefinitionListNode(
1✔
202
                        $items,
1✔
203
                        Modifier::parse($mMod),
1✔
204
                        new Position($offsets[0], strlen($matches[0])),
1✔
205
                );
206
        }
207

208

209
        /**
210
         * Parses single list item.
211
         */
212
        private function parseItem(ParseContext $context, string $bullet, bool $indented): ?ListItemNode
1✔
213
        {
214
                $spacesBase = $indented ? ('[\ \t]{1,}') : '';
1✔
215
                $patternItem = "~^
1✔
216
                        \\n?
217
                        ($spacesBase)                            # base indentation (1)
1✔
218
                        {$bullet}                                # bullet character
1✔
219
                        [ \\t]*
220
                        ( \\S .* )?                              # content (2)
221
                        " . Patterns::MODIFIER_H . '?           # modifier (3)
1✔
222
                $~mAU';
223

224
                $matches = null;
1✔
225
                $offsets = null;
1✔
226
                if (!$context->getBlockParser()->next($patternItem, $matches, $offsets)) {
1✔
227
                        return null;
1✔
228
                }
229

230
                [, $mIndent, $mContent, $mMod] = $matches;
1✔
231

232
                // Collect lines with their absolute offsets
233
                $lines = [];
1✔
234
                $contentOffset = $offsets[2] ?? $offsets[0]; // offset of first line content
1✔
235
                if ($mContent !== null) {
1✔
236
                        $lines[] = ['content' => $mContent, 'offset' => $contentOffset];
1✔
237
                }
238

239
                // next lines
240
                $spaces = '';
1✔
241
                $content = ' ' . ($mContent ?? '');
1✔
242
                while ($context->getBlockParser()->next('~^
1✔
243
                        (\n*)
244
                        ' . Regexp::quote($mIndent) . '
1✔
245
                        ([ \t]{1,' . $spaces . '})
1✔
246
                        (.*)                                     # content (3)
247
                $~Am', $matches, $offsets)) {
248
                        [, $mBlank, $mSpaces, $mContent] = $matches;
1✔
249

250
                        if ($spaces === '') {
1✔
251
                                $spaces = strlen($mSpaces);
1✔
252
                        }
253

254
                        $content .= "\n" . $mBlank . $mContent;
1✔
255

256
                        // Track content offset
257
                        if ($mContent !== '' && $offsets[3] !== null) {
1✔
258
                                $lines[] = ['content' => $mContent, 'offset' => $offsets[3]];
1✔
259
                        }
260
                }
261

262
                // Parse content as blocks
263
                $parsed = $context->parseBlock(trim($content));
1✔
264

265
                // Fix positions in parsed content using offset mapping
266
                if ($lines) {
1✔
267
                        $this->fixPositions($parsed, $lines);
1✔
268
                }
269

270
                return new ListItemNode(
1✔
271
                        $parsed,
1✔
272
                        false,
1✔
273
                        Modifier::parse($mMod),
1✔
274
                );
275
        }
276

277

278
        /**
279
         * Fix positions in parsed content using offset mapping.
280
         * @param array<array{content: string, offset: int}> $lines
281
         */
282
        private function fixPositions(Texy\Node $node, array $lines): void
1✔
283
        {
284
                if ($node->position !== null) {
1✔
285
                        $localOffset = $node->position->offset;
1✔
286

287
                        // Find which line this position starts on
288
                        $offsetMap = [];
1✔
289
                        $localPos = 0;
1✔
290
                        foreach ($lines as $line) {
1✔
291
                                $offsetMap[$localPos] = $line['offset'];
1✔
292
                                $localPos += strlen($line['content']) + 1; // +1 for \n
1✔
293
                        }
294

295
                        $lineStart = 0;
1✔
296
                        $lineKeys = array_keys($offsetMap);
1✔
297
                        foreach ($lineKeys as $localLineStart) {
1✔
298
                                if ($localLineStart <= $localOffset) {
1✔
299
                                        $lineStart = $localLineStart;
1✔
300
                                } else {
301
                                        break;
1✔
302
                                }
303
                        }
304

305
                        $absoluteStart = $offsetMap[$lineStart] ?? $lines[0]['offset'];
1✔
306
                        $newOffset = $absoluteStart + ($localOffset - $lineStart);
1✔
307

308
                        $node->position = new Position($newOffset, $node->position->length);
1✔
309
                }
310

311
                foreach ($node->getNodes() as $child) {
1✔
312
                        $this->fixPositions($child, $lines);
1✔
313
                }
314
        }
1✔
315
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc