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