xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmHeaderTest.php (revision 75364f13219a5af44f52c564ea0a62df64c3a17f)
1<?php
2
3namespace dokuwiki\test\Parsing\ParserMode;
4
5use dokuwiki\Parsing\ParserMode\Eol;
6use dokuwiki\Parsing\ParserMode\GfmHeader;
7
8/**
9 * Tests for GFM ATX headings (`# text` through `###### text`).
10 */
11class GfmHeaderTest extends ParserTestBase
12{
13    public function setUp(): void
14    {
15        parent::setUp();
16        $this->setSyntax('md');
17    }
18
19    function testLevelOne()
20    {
21        $this->P->addMode('gfm_header', new GfmHeader());
22        $this->P->parse("abc\n# Header\ndef");
23        $calls = [
24            ['document_start', []],
25            ['p_open', []],
26            ['cdata', ["\nabc"]],
27            ['p_close', []],
28            // pos points at the `#` (index 5), not the newline before it
29            // that the entry pattern's lookbehind matches; see testPosPointsAtHash
30            ['header', ['Header', 1, 5]],
31            ['section_open', [1]],
32            ['p_open', []],
33            ['cdata', ["\ndef"]],
34            ['p_close', []],
35            ['section_close', []],
36            ['document_end', []],
37        ];
38        $this->assertCalls($calls, $this->H->calls);
39    }
40
41    function testAllLevels()
42    {
43        foreach ([1, 2, 3, 4, 5, 6] as $level) {
44            $this->setUp();
45            $this->P->addMode('gfm_header', new GfmHeader());
46            $marker = str_repeat('#', $level);
47            $this->P->parse("$marker foo");
48            $calls = array_column($this->H->calls, 0);
49            $this->assertContains('header', $calls, "level $level must emit header");
50
51            $headerCall = array_values(array_filter(
52                $this->H->calls,
53                static fn($c) => $c[0] === 'header'
54            ))[0];
55            $this->assertSame('foo', $headerCall[1][0], "level $level title");
56            $this->assertSame($level, $headerCall[1][1], "level $level level");
57        }
58    }
59
60    function testSevenHashesIsNotAHeading()
61    {
62        $this->P->addMode('gfm_header', new GfmHeader());
63        $this->P->parse('####### foo');
64        $modes = array_column($this->H->calls, 0);
65        $this->assertNotContains('header', $modes,
66            'A run of 7 `#` must not open an ATX heading');
67    }
68
69    function testHashTouchingTextIsNotAHeading()
70    {
71        $this->P->addMode('gfm_header', new GfmHeader());
72        $this->P->parse("#5 bolt\n\n#hashtag");
73        $modes = array_column($this->H->calls, 0);
74        $this->assertNotContains('header', $modes,
75            'A `#` directly followed by a non-space char must not open a heading');
76    }
77
78    function testEmptyHeading()
79    {
80        $this->P->addMode('gfm_header', new GfmHeader());
81        $this->P->parse("#\n");
82        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
83        $this->assertCount(1, $headerCalls, 'bare `#` must still emit a heading');
84        $call = array_values($headerCalls)[0];
85        $this->assertSame('', $call[1][0]);
86        $this->assertSame(1, $call[1][1]);
87    }
88
89    function testEmptyHeadingWithTrailingSpace()
90    {
91        $this->P->addMode('gfm_header', new GfmHeader());
92        $this->P->parse("## \n");
93        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
94        $call = array_values($headerCalls)[0];
95        $this->assertSame('', $call[1][0]);
96        $this->assertSame(2, $call[1][1]);
97    }
98
99    function testEmptyHeadingWithClosingHashes()
100    {
101        $this->P->addMode('gfm_header', new GfmHeader());
102        $this->P->parse("### ###\n");
103        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
104        $call = array_values($headerCalls)[0];
105        $this->assertSame('', $call[1][0]);
106        $this->assertSame(3, $call[1][1]);
107    }
108
109    function testOptionalClosingHashesStripped()
110    {
111        $this->P->addMode('gfm_header', new GfmHeader());
112        $this->P->parse("## foo ##\n");
113        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
114        $call = array_values($headerCalls)[0];
115        $this->assertSame('foo', $call[1][0]);
116        $this->assertSame(2, $call[1][1]);
117    }
118
119    function testClosingNeedNotMatchOpeningLength()
120    {
121        $this->P->addMode('gfm_header', new GfmHeader());
122        $this->P->parse("# foo ##################################\n");
123        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
124        $call = array_values($headerCalls)[0];
125        $this->assertSame('foo', $call[1][0]);
126        $this->assertSame(1, $call[1][1]);
127    }
128
129    function testTrailingSpacesAfterClosing()
130    {
131        $this->P->addMode('gfm_header', new GfmHeader());
132        $this->P->parse("### foo ###     \n");
133        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
134        $call = array_values($headerCalls)[0];
135        $this->assertSame('foo', $call[1][0]);
136        $this->assertSame(3, $call[1][1]);
137    }
138
139    function testClosingRunFollowedByTextIsNotClosing()
140    {
141        $this->P->addMode('gfm_header', new GfmHeader());
142        $this->P->parse("### foo ### b\n");
143        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
144        $call = array_values($headerCalls)[0];
145        $this->assertSame('foo ### b', $call[1][0]);
146        $this->assertSame(3, $call[1][1]);
147    }
148
149    function testClosingHashMustBePrecededBySpace()
150    {
151        $this->P->addMode('gfm_header', new GfmHeader());
152        $this->P->parse("# foo#\n");
153        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
154        $call = array_values($headerCalls)[0];
155        $this->assertSame('foo#', $call[1][0]);
156        $this->assertSame(1, $call[1][1]);
157    }
158
159    function testIndentedHashIsNotAHeading()
160    {
161        // GFM tolerates 0-3 spaces of indent; we do not. Any leading
162        // whitespace makes the line a paragraph (or preformatted, if
163        // it meets that mode's rules).
164        foreach ([1, 2, 3] as $indent) {
165            $this->setUp();
166            $this->P->addMode('gfm_header', new GfmHeader());
167            $this->P->parse(str_repeat(' ', $indent) . '### foo');
168            $modes = array_column($this->H->calls, 0);
169            $this->assertNotContains('header', $modes,
170                "indent=$indent must NOT open a heading");
171        }
172    }
173
174    function testContentInlineWhitespaceCollapsed()
175    {
176        $this->P->addMode('gfm_header', new GfmHeader());
177        $this->P->parse("#                  foo                     \n");
178        $headerCalls = array_filter($this->H->calls, static fn($c) => $c[0] === 'header');
179        $call = array_values($headerCalls)[0];
180        $this->assertSame('foo', $call[1][0]);
181    }
182
183    function testHeadingCanInterruptParagraph()
184    {
185        $this->P->addMode('gfm_header', new GfmHeader());
186        $this->P->addMode('eol', new Eol());
187        $this->P->parse("Foo bar\n# baz\nBar foo");
188        $modes = array_column($this->H->calls, 0);
189        $this->assertContains('header', $modes,
190            'ATX headings must interrupt paragraphs without requiring a blank line');
191    }
192
193    function testSortValue()
194    {
195        $mode = new GfmHeader();
196        $this->assertSame(50, $mode->getSort());
197    }
198
199    /**
200     * The entry pattern anchors the hashes to column 0 with a lookbehind on
201     * the preceding newline, so the reported position points at the first `#`
202     * rather than the newline. This keeps the blank line above the heading in
203     * the previous section instead of eating it on section edit. See PR #4636.
204     */
205    function testPosPointsAtHash()
206    {
207        // parse() prepends a newline, so with a blank line above the heading
208        // the doc is "\n# top\n\n## sub\n". The `#` of "## sub" sits at index 8.
209        $this->P->addMode('gfm_header', new GfmHeader());
210        $this->P->parse("# top\n\n## sub\n");
211
212        $headers = array_values(array_filter(
213            $this->H->calls,
214            static fn($c) => $c[0] === 'header'
215        ));
216        $this->assertCount(2, $headers);
217
218        // first heading: the leading `#` follows only the prepended newline
219        $this->assertSame('top', $headers[0][1][0]);
220        $this->assertSame(1, $headers[0][2], 'pos must point at the first `#`');
221
222        // second heading: pos must skip both the line break and the blank
223        // line, landing on the `#` rather than the blank line's newline
224        $this->assertSame('sub', $headers[1][1][0]);
225        $this->assertSame(8, $headers[1][2],
226            'pos must point at the `#`, not the blank line above it');
227    }
228}
229