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

dg / texy / 29698730446

19 Jul 2026 06:14PM UTC coverage: 93.477% (+0.4%) from 93.08%
29698730446

push

github

dg
PHPStan ignore paths follow the output layer split

3296 of 3526 relevant lines covered (93.48%)

0.93 hits per line

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

93.52
/src/Texy/Modules/HeadingModule.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\HeadingNode;
13
use Texy\Nodes\HeadingType;
14
use Texy\ParseContext;
15
use Texy\Range;
16
use Texy\Syntax;
17
use function array_flip, array_values, asort, max, min, rtrim, strlen, trim;
18

19

20
/**
21
 * Processes heading syntax (underlined and surrounded) and builds table of contents.
22
 */
23
final class HeadingModule extends Texy\Module
24
{
25
        public const
26
                Dynamic = 1, // auto-leveling
27
                Fixed = 2; // fixed-leveling
28

29
        #[\Deprecated('use HeadingModule::Dynamic')]
30
        public const DYNAMIC = self::Dynamic;
31

32
        #[\Deprecated('use HeadingModule::Fixed')]
33
        public const FIXED = self::Fixed;
34

35
        /** textual content of first heading */
36
        public ?string $title = null;
37

38
        /** @var list<array{node: HeadingNode, title?: string}>  generated Table of Contents */
39
        public array $TOC = [];
40

41
        /** level of top heading, 1..6 */
42
        public int $top = 1;
43

44
        /** surrounded headings: more #### means higher heading */
45
        public bool $moreMeansHigher = true;
46

47
        /** balancing mode */
48
        public int $balancing = self::Dynamic;
49

50
        /** @var array<string, int>  when $balancing = HeadingModule::FIXED */
51
        public array $levels = [
52
                '#' => 0, // # --> $levels['#'] + $top = 0 + 1 = 1 --> <h1> ... </h1>
53
                '*' => 1,
54
                '=' => 2,
55
                '-' => 3,
56
        ];
57

58
        /** generate ID for headings? */
59
        public bool $generateID = false;
60

61
        /** prefix for autogenerated IDs */
62
        public string $idPrefix = 'toc-';
63

64
        /** @var array<string, true>  used ID's */
65
        private array $usedID = [];
66

67

68
        public function __construct(
1✔
69
                private Texy\Texy $texy,
70
        ) {
71
                $texy->addHandler('afterParse', $this->afterParse(...));
1✔
72
        }
1✔
73

74

75
        public function beforeParse(string &$text): void
1✔
76
        {
77
                $this->texy->registerBlockPattern(
1✔
78
                        $this->parseUnderline(...),
1✔
79
                        '~^
80
                                ( \S .{0,1000} )                 # heading text (1)
81
                                ' . Texy\Patterns::ModifierHAlign . '? # modifier (2)
1✔
82
                                \n
83
                                ( \#{3,}+ | \*{3,}+ | ={3,}+ | -{3,}+ )  # underline characters (3)
84
                        $~mUx',
85
                        Syntax::HeadingUnderlined,
1✔
86
                );
87

88
                $this->texy->registerBlockPattern(
1✔
89
                        $this->parseSurround(...),
1✔
90
                        '~^
91
                                ( \#{2,}+ | ={2,}+ )             # opening characters (1)
92
                                (.+)                             # heading text (2)
93
                                ' . Texy\Patterns::ModifierHAlign . '? # modifier (2)
1✔
94
                        $~mUx',
95
                        Syntax::HeadingSurrounded,
1✔
96
                );
97

98
                $this->title = null;
1✔
99
                $this->usedID = [];
1✔
100
                $this->TOC = [];
1✔
101
        }
1✔
102

103

104
        /**
105
         * Post-process AST headings - apply balancing and calculate final levels.
106
         */
107
        public function afterParse(Texy\Nodes\DocumentNode $document): void
1✔
108
        {
109
                // Collect all heading nodes (separated: dynamic balancing vs fixed balancing)
110
                [$dynamicHeadings, $fixedHeadings] = $this->collectHeadings($document);
1✔
111

112
                // Apply fixed balancing to texysource headings (just add top)
113
                foreach ($fixedHeadings as $node) {
1✔
114
                        $node->level = min(6, max(1, $node->level + $this->top));
×
115
                }
116

117
                // Process main document headings
118
                $headings = $dynamicHeadings;
1✔
119
                if (!$headings) {
1✔
120
                        // Still need to process ID generation for fixed headings
121
                        $headings = $fixedHeadings;
1✔
122
                        if (!$headings) {
1✔
123
                                return;
1✔
124
                        }
125
                } elseif ($this->balancing === self::Dynamic) {
1✔
126
                        $map = [];
1✔
127
                        $min = 100;
1✔
128

129
                        // First pass: collect level information
130
                        foreach ($headings as $node) {
1✔
131
                                $level = $node->level;
1✔
132
                                match ($node->type) {
1✔
133
                                        HeadingType::Surrounded => $min = min($level, $min),
1✔
134
                                        HeadingType::Underlined => $map[$level] = $level,
1✔
135
                                };
136
                        }
137

138
                        // Calculate top offset for surrounded headings
139
                        $top = $this->top - $min;
1✔
140

141
                        // Sort underlined levels and create mapping
142
                        asort($map);
1✔
143
                        $map = array_flip(array_values($map));
1✔
144

145
                        // Second pass: apply calculated levels
146
                        foreach ($headings as $node) {
1✔
147
                                $level = match ($node->type) {
1✔
148
                                        HeadingType::Surrounded => $node->level + $top,
1✔
149
                                        HeadingType::Underlined => $map[$node->level] + $this->top,
1✔
150
                                };
151
                                $node->level = min(6, max(1, $level));
1✔
152
                        }
153
                } else {
154
                        // FIXED balancing - just add top
155
                        foreach ($headings as $node) {
×
156
                                $node->level = min(6, max(1, $node->level + $this->top));
×
157
                        }
158
                }
159

160
                // Generate IDs if enabled (only for main document headings)
161
                if ($this->generateID) {
1✔
162
                        foreach ($dynamicHeadings as $node) {
1✔
163
                                // Check for custom TOC title in style
164
                                if ($node->modifier !== null && isset($node->modifier->styles['toc'])) {
1✔
165
                                        $title = $node->modifier->styles['toc'];
1✔
166
                                        unset($node->modifier->styles['toc']);
1✔
167
                                } else {
168
                                        $title = trim(Texy\Helpers::extractText($node));
1✔
169
                                }
170

171
                                // Skip if ID already set in modifier
172
                                if ($node->modifier?->id) {
1✔
173
                                        $this->usedID[$node->modifier->id] = true;
×
174

175
                                        continue;
×
176
                                }
177

178
                                $id = $this->idPrefix . Texy\Helpers::webalize($title);
1✔
179
                                $counter = '';
1✔
180
                                if (isset($this->usedID[$id . $counter])) {
1✔
181
                                        $counter = 2;
1✔
182
                                        while (isset($this->usedID[$id . '-' . $counter])) {
1✔
183
                                                $counter++;
1✔
184
                                        }
185

186
                                        $id .= '-' . $counter;
1✔
187
                                }
188

189
                                $this->usedID[$id] = true;
1✔
190

191
                                // Create modifier if not exists
192
                                if ($node->modifier === null) {
1✔
193
                                        $node->modifier = new Modifier;
1✔
194
                                }
195

196
                                $node->modifier->id = $id;
1✔
197

198
                        }
199
                }
200

201
                // Set document title from first heading (main document only)
202
                if ($this->title === null && $dynamicHeadings) {
1✔
203
                        $this->title = trim(Texy\Helpers::extractText($dynamicHeadings[0]));
1✔
204
                }
205

206
                // Build TOC (only for main document headings)
207
                foreach ($dynamicHeadings as $node) {
1✔
208
                        $entry = ['node' => $node];
1✔
209
                        if ($this->generateID) {
1✔
210
                                $entry['title'] = trim(Texy\Helpers::extractText($node));
1✔
211
                        }
212

213
                        $this->TOC[] = $entry;
1✔
214
                }
215
        }
1✔
216

217

218
        /**
219
         * Parses underlined heading.
220
         * @param  array<?string>  $matches
221
         * @param  array<?int>  $offsets
222
         */
223
        public function parseUnderline(ParseContext $context, array $matches, array $offsets): HeadingNode
1✔
224
        {
225
                /** @var array{string, string, ?string, string} $matches */
226
                [, $mContent, $mMod, $mLine] = $matches;
1✔
227
                $level = $this->levels[$mLine[0]];
1✔
228
                $contentOffset = $offsets[1] ?? $offsets[0];
1✔
229
                return new HeadingNode(
1✔
230
                        $context->parseInline(trim($mContent), $contentOffset),
1✔
231
                        $level,
232
                        HeadingType::Underlined,
1✔
233
                        Modifier::parse($mMod, $offsets[2] ?? null),
1✔
234
                        new Range($offsets[0], strlen($matches[0])),
1✔
235
                );
236
        }
237

238

239
        /**
240
         * Parses surrounded heading.
241
         * @param  array<?string>  $matches
242
         * @param  array<?int>  $offsets
243
         */
244
        public function parseSurround(ParseContext $context, array $matches, array $offsets): HeadingNode
1✔
245
        {
246
                /** @var array{string, string, string, ?string} $matches */
247
                [, $mLine, $mContent, $mMod] = $matches;
1✔
248
                $level = min(7, max(2, strlen($mLine)));
1✔
249
                $level = $this->moreMeansHigher ? 7 - $level : $level - 2;
1✔
250
                $mContent = rtrim($mContent, $mLine[0] . ' ');
1✔
251
                $contentOffset = $offsets[2] ?? $offsets[0];
1✔
252

253
                // Adjust offset for leading whitespace removed by trim
254
                $trimmed = ltrim($mContent);
1✔
255
                $leadingSpaces = strlen($mContent) - strlen($trimmed);
1✔
256
                $contentOffset += $leadingSpaces;
1✔
257

258
                return new HeadingNode(
1✔
259
                        $context->parseInline(trim($mContent), $contentOffset),
1✔
260
                        $level,
261
                        HeadingType::Surrounded,
1✔
262
                        Modifier::parse($mMod, $offsets[3] ?? null),
1✔
263
                        new Range($offsets[0], strlen($matches[0])),
1✔
264
                );
265
        }
266

267

268
        /**
269
         * Collect all HeadingNode from AST.
270
         * Headings inside texysource sections are collected separately (for fixed balancing).
271
         * @return array{list<HeadingNode>, list<HeadingNode>}  [headings for dynamic balancing, headings for fixed balancing]
272
         */
273
        private function collectHeadings(Texy\Node $node, bool $inTexysource = false): array
1✔
274
        {
275
                // Check if entering texysource section
276
                if ($node instanceof Texy\Nodes\SectionNode && $node->type === 'texysource') {
1✔
277
                        $inTexysource = true;
×
278
                }
279

280
                $dynamic = [];
1✔
281
                $fixed = [];
1✔
282

283
                if ($node instanceof HeadingNode) {
1✔
284
                        if ($inTexysource) {
1✔
285
                                $fixed[] = $node;
×
286
                        } else {
287
                                $dynamic[] = $node;
1✔
288
                        }
289
                }
290

291
                foreach ($node->getChildren() as $child) {
1✔
292
                        [$childDynamic, $childFixed] = $this->collectHeadings($child, $inTexysource);
1✔
293
                        $dynamic = [...$dynamic, ...$childDynamic];
1✔
294
                        $fixed = [...$fixed, ...$childFixed];
1✔
295
                }
296

297
                return [$dynamic, $fixed];
1✔
298
        }
299
}
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