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

dg / texy / 29654833201

18 Jul 2026 04:09PM UTC coverage: 93.039% (+0.04%) from 93.004%
29654833201

push

github

dg
Restored test coverage dropped during the AST migration

The migration removed five test files coupled to v3 contracts (block,
content-model, formatter-indent, html, latte) with the intent to bring
them back once the output layer settled. Restored here, ported to the
AST-era API; latte and most of content-model pass unchanged.

Expected outputs were re-derived and every difference against the v3
files reviewed case by case. The differences fall into the intentional
categories: list items merge continuation lines, whitespace-sensitive
typography follows visible length instead of protection-mark key length,
rejected tags are escaped consistently (both halves of a pair) with
typography applied to the escaped text, paragraphs of escaped markup are
<p>-wrapped, and <p> inside <form> is preserved. Inner texysource
fragments are parsed without the transform phase, so their passthrough
tags stay live - a documented wrinkle.

content-model.phpt again guards the WellFormer content model, nesting
prohibitions, transparent and unknown elements and auto-closing in the
public suite, independently of the private regression corpus.

3248 of 3491 relevant lines covered (93.04%)

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\Position;
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
        /** textual content of first heading */
30
        public ?string $title = null;
31

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

35
        /** level of top heading, 1..6 */
36
        public int $top = 1;
37

38
        /** surrounded headings: more #### means higher heading */
39
        public bool $moreMeansHigher = true;
40

41
        /** balancing mode */
42
        public int $balancing = self::DYNAMIC;
43

44
        /** @var array<string, int>  when $balancing = HeadingModule::FIXED */
45
        public array $levels = [
46
                '#' => 0, // # --> $levels['#'] + $top = 0 + 1 = 1 --> <h1> ... </h1>
47
                '*' => 1,
48
                '=' => 2,
49
                '-' => 3,
50
        ];
51

52
        /** generate ID for headings? */
53
        public bool $generateID = false;
54

55
        /** prefix for autogenerated IDs */
56
        public string $idPrefix = 'toc-';
57

58
        /** @var array<string, true>  used ID's */
59
        private array $usedID = [];
60

61

62
        public function __construct(
1✔
63
                private Texy\Texy $texy,
64
        ) {
65
                $texy->addHandler('afterParse', $this->afterParse(...));
1✔
66
        }
1✔
67

68

69
        public function beforeParse(string &$text): void
1✔
70
        {
71
                $this->texy->registerBlockPattern(
1✔
72
                        $this->parseUnderline(...),
1✔
73
                        '~^
74
                                ( \S .{0,1000} )                 # heading text (1)
75
                                ' . Texy\Patterns::MODIFIER_H . '? # modifier (2)
1✔
76
                                \n
77
                                ( \#{3,}+ | \*{3,}+ | ={3,}+ | -{3,}+ )  # underline characters (3)
78
                        $~mU',
79
                        Syntax::HeadingUnderlined,
1✔
80
                );
81

82
                $this->texy->registerBlockPattern(
1✔
83
                        $this->parseSurround(...),
1✔
84
                        '~^
85
                                ( \#{2,}+ | ={2,}+ )             # opening characters (1)
86
                                (.+)                             # heading text (2)
87
                                ' . Texy\Patterns::MODIFIER_H . '? # modifier (2)
1✔
88
                        $~mU',
89
                        Syntax::HeadingSurrounded,
1✔
90
                );
91

92
                $this->title = null;
1✔
93
                $this->usedID = [];
1✔
94
                $this->TOC = [];
1✔
95
        }
1✔
96

97

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

106
                // Apply fixed balancing to texysource headings (just add top)
107
                foreach ($fixedHeadings as $node) {
1✔
108
                        $node->level = min(6, max(1, $node->level + $this->top));
×
109
                }
110

111
                // Process main document headings
112
                $headings = $dynamicHeadings;
1✔
113
                if (!$headings) {
1✔
114
                        // Still need to process ID generation for fixed headings
115
                        $headings = $fixedHeadings;
1✔
116
                        if (!$headings) {
1✔
117
                                return;
1✔
118
                        }
119
                } elseif ($this->balancing === self::DYNAMIC) {
1✔
120
                        $map = [];
1✔
121
                        $min = 100;
1✔
122

123
                        // First pass: collect level information
124
                        foreach ($headings as $node) {
1✔
125
                                $level = $node->level;
1✔
126
                                match ($node->type) {
1✔
127
                                        HeadingType::Surrounded => $min = min($level, $min),
1✔
128
                                        HeadingType::Underlined => $map[$level] = $level,
1✔
129
                                };
130
                        }
131

132
                        // Calculate top offset for surrounded headings
133
                        $top = $this->top - $min;
1✔
134

135
                        // Sort underlined levels and create mapping
136
                        asort($map);
1✔
137
                        $map = array_flip(array_values($map));
1✔
138

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

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

165
                                // Skip if ID already set in modifier
166
                                if ($node->modifier?->id) {
1✔
167
                                        $this->usedID[$node->modifier->id] = true;
×
168

169
                                        continue;
×
170
                                }
171

172
                                $id = $this->idPrefix . Texy\Helpers::webalize($title);
1✔
173
                                $counter = '';
1✔
174
                                if (isset($this->usedID[$id . $counter])) {
1✔
175
                                        $counter = 2;
1✔
176
                                        while (isset($this->usedID[$id . '-' . $counter])) {
1✔
177
                                                $counter++;
1✔
178
                                        }
179

180
                                        $id .= '-' . $counter;
1✔
181
                                }
182

183
                                $this->usedID[$id] = true;
1✔
184

185
                                // Create modifier if not exists
186
                                if ($node->modifier === null) {
1✔
187
                                        $node->modifier = new Modifier;
1✔
188
                                }
189

190
                                $node->modifier->id = $id;
1✔
191

192
                        }
193
                }
194

195
                // Set document title from first heading (main document only)
196
                if ($this->title === null && $dynamicHeadings) {
1✔
197
                        $this->title = trim(Texy\Helpers::extractText($dynamicHeadings[0]));
1✔
198
                }
199

200
                // Build TOC (only for main document headings)
201
                foreach ($dynamicHeadings as $node) {
1✔
202
                        $entry = ['node' => $node];
1✔
203
                        if ($this->generateID) {
1✔
204
                                $entry['title'] = trim(Texy\Helpers::extractText($node));
1✔
205
                        }
206

207
                        $this->TOC[] = $entry;
1✔
208
                }
209
        }
1✔
210

211

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

232

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

247
                // Adjust offset for leading whitespace removed by trim
248
                $trimmed = ltrim($mContent);
1✔
249
                $leadingSpaces = strlen($mContent) - strlen($trimmed);
1✔
250
                $contentOffset += $leadingSpaces;
1✔
251

252
                return new HeadingNode(
1✔
253
                        $context->parseInline(trim($mContent), $contentOffset),
1✔
254
                        $level,
255
                        HeadingType::Surrounded,
1✔
256
                        Modifier::parse($mMod),
1✔
257
                        new Position($offsets[0], strlen($matches[0])),
1✔
258
                );
259
        }
260

261

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

274
                $dynamic = [];
1✔
275
                $fixed = [];
1✔
276

277
                if ($node instanceof HeadingNode) {
1✔
278
                        if ($inTexysource) {
1✔
279
                                $fixed[] = $node;
×
280
                        } else {
281
                                $dynamic[] = $node;
1✔
282
                        }
283
                }
284

285
                foreach ($node->getNodes() as $child) {
1✔
286
                        [$childDynamic, $childFixed] = $this->collectHeadings($child, $inTexysource);
1✔
287
                        $dynamic = [...$dynamic, ...$childDynamic];
1✔
288
                        $fixed = [...$fixed, ...$childFixed];
1✔
289
                }
290

291
                return [$dynamic, $fixed];
1✔
292
        }
293
}
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