1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\ParserMode\GfmEmphasisUnderscore; 6 7/** 8 * Tests for the GFM underscore emphasis mode (`_text_`). 9 */ 10class GfmEmphasisUnderscoreTest extends ParserTestBase 11{ 12 public function setUp(): void 13 { 14 parent::setUp(); 15 $this->setSyntax('md'); 16 } 17 18 public function testBasicUnderscore() 19 { 20 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 21 $this->P->parse('Foo _Bar_ Baz'); 22 23 $calls = [ 24 ['document_start', []], 25 ['p_open', []], 26 ['cdata', ["\nFoo "]], 27 ['emphasis_open', []], 28 ['cdata', ['Bar']], 29 ['emphasis_close', []], 30 ['cdata', [' Baz']], 31 ['p_close', []], 32 ['document_end', []], 33 ]; 34 $this->assertCalls($calls, $this->H->calls); 35 } 36 37 public function testSingleCharacter() 38 { 39 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 40 $this->P->parse('foo _b_ bar'); 41 42 $calls = [ 43 ['document_start', []], 44 ['p_open', []], 45 ['cdata', ["\nfoo "]], 46 ['emphasis_open', []], 47 ['cdata', ['b']], 48 ['emphasis_close', []], 49 ['cdata', [' bar']], 50 ['p_close', []], 51 ['document_end', []], 52 ]; 53 $this->assertCalls($calls, $this->H->calls); 54 } 55 56 public function testMultipleWords() 57 { 58 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 59 $this->P->parse('_one two three_'); 60 61 $calls = [ 62 ['document_start', []], 63 ['p_open', []], 64 ['cdata', ["\n"]], 65 ['emphasis_open', []], 66 ['cdata', ['one two three']], 67 ['emphasis_close', []], 68 ['cdata', ['']], 69 ['p_close', []], 70 ['document_end', []], 71 ]; 72 $this->assertCalls($calls, $this->H->calls); 73 } 74 75 public function testIntrawordUnderscoreIsNotEmphasised() 76 { 77 // GFM's key word-boundary rule: underscores inside words stay literal. 78 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 79 $this->P->parse('this_is_not_an_emphasis'); 80 81 $calls = [ 82 ['document_start', []], 83 ['p_open', []], 84 ['cdata', ["\nthis_is_not_an_emphasis"]], 85 ['p_close', []], 86 ['document_end', []], 87 ]; 88 $this->assertCalls($calls, $this->H->calls); 89 } 90 91 public function testOpenerFollowedBySpaceDoesNotEmphasise() 92 { 93 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 94 $this->P->parse('foo _ bar_ baz'); 95 96 $calls = [ 97 ['document_start', []], 98 ['p_open', []], 99 ['cdata', ["\nfoo _ bar_ baz"]], 100 ['p_close', []], 101 ['document_end', []], 102 ]; 103 $this->assertCalls($calls, $this->H->calls); 104 } 105 106 public function testDoubleUnderscoreDoesNotEmphasise() 107 { 108 // `__foo__` must stay literal. At the first `_`, the lookahead 109 // `(?=[^\s_])` forbids entry (next char is another `_`). At the 110 // second `_`, the lookbehind also fails because `_` itself is not 111 // a "non-word" character (it's excluded from NON_WORD_CHAR so that 112 // `__foo` can't open emphasis at the inner underscore). 113 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 114 $this->P->parse('foo __bar__ baz'); 115 116 $calls = [ 117 ['document_start', []], 118 ['p_open', []], 119 ['cdata', ["\nfoo __bar__ baz"]], 120 ['p_close', []], 121 ['document_end', []], 122 ]; 123 $this->assertCalls($calls, $this->H->calls); 124 } 125 126 public function testTwoSeparateEmphasisOnOneLine() 127 { 128 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 129 $this->P->parse('_one_ and _two_'); 130 131 $calls = [ 132 ['document_start', []], 133 ['p_open', []], 134 ['cdata', ["\n"]], 135 ['emphasis_open', []], 136 ['cdata', ['one']], 137 ['emphasis_close', []], 138 ['cdata', [' and ']], 139 ['emphasis_open', []], 140 ['cdata', ['two']], 141 ['emphasis_close', []], 142 ['cdata', ['']], 143 ['p_close', []], 144 ['document_end', []], 145 ]; 146 $this->assertCalls($calls, $this->H->calls); 147 } 148 149 public function testMultilineEmphasis() 150 { 151 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 152 $this->P->parse("_line\nline\nline_"); 153 154 $calls = [ 155 ['document_start', []], 156 ['p_open', []], 157 ['cdata', ["\n"]], 158 ['emphasis_open', []], 159 ['cdata', ["line\nline\nline"]], 160 ['emphasis_close', []], 161 ['cdata', ['']], 162 ['p_close', []], 163 ['document_end', []], 164 ]; 165 $this->assertCalls($calls, $this->H->calls); 166 } 167 168 public function testSortValue() 169 { 170 $mode = new GfmEmphasisUnderscore(); 171 $this->assertSame(80, $mode->getSort()); 172 } 173 174 public function testDoesNotSpanParagraphBoundary() 175 { 176 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 177 $this->P->parse("_open\n\nclose_"); 178 179 $modes = array_column($this->H->calls, 0); 180 $this->assertNotContains('emphasis_open', $modes, 181 'GfmEmphasisUnderscore must not open when the closing `_` is past a blank line'); 182 } 183 184 public function testAllowsSingleNewlineInsideMultiline() 185 { 186 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 187 $this->P->parse("_open\nclose_"); 188 189 $modes = array_column($this->H->calls, 0); 190 $this->assertContains('emphasis_open', $modes, 191 'GfmEmphasisUnderscore must still match across a single newline'); 192 } 193 194 /** 195 * The intraword rule must apply to multibyte letters, not just ASCII. 196 * This test is derived from CommonMark spec §6.2 example 418: 197 * 198 * пристаням_стремятся_ 199 * 200 * which must render as literal (no emphasis). The surrounding Cyrillic 201 * letters are word-like; the underscores are intraword and must not 202 * emphasize. 203 * 204 * The word-boundary constants (NO_WORD_BEFORE / NO_WORD_AFTER) are 205 * defined positively (matching explicit non-word chars) rather than 206 * negatively (not matching a-zA-Z0-9), so multibyte UTF-8 bytes — which 207 * are not in any ASCII class — are correctly treated as word-like. 208 * 209 * @dataProvider provideMultibyteIntrawordCases 210 */ 211 public function testIntrawordUnderscoreInMultibyteText(string $input) 212 { 213 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 214 $this->P->parse($input); 215 216 $modes = array_column($this->H->calls, 0); 217 $this->assertNotContains( 218 'emphasis_open', 219 $modes, 220 "Intraword `_` in multibyte text must not emphasize: " . json_encode($input) 221 ); 222 } 223 224 public static function provideMultibyteIntrawordCases(): array 225 { 226 return [ 227 // CommonMark spec §6.2 ex. 418 — Cyrillic intraword 228 'cyrillic-trailing' => ['пристаням_стремятся_'], 229 // CommonMark spec §6.2 ex. 420 — Cyrillic leading 230 'cyrillic-leading' => ['_пристаням_стремятся'], 231 // German umlaut — no established spec example, but the expected 232 // behavior is uncontroversial: intraword `_` stays literal. 233 'german-umlaut' => ['für_etwas_text'], 234 // CJK — same expectation 235 'cjk-intraword' => ['日本_語_の'], 236 // Greek 237 'greek-intraword' => ['αυτό_είναι_κείμενο'], 238 ]; 239 } 240 241 /** 242 * A `_foo_` span surrounded by multibyte letters must NOT open at the 243 * first `_` (it would be intraword) AND must still NOT open if the 244 * following letters are multibyte. Verifies that both the lookbehind 245 * and the closing-delimiter lookahead reject multibyte word chars. 246 */ 247 public function testMultibyteWordCharsAreNotTreatedAsBoundary() 248 { 249 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 250 // Intraword between Cyrillic on the left and Cyrillic on the right. 251 $this->P->parse('до_середины_текста'); 252 253 $modes = array_column($this->H->calls, 0); 254 $this->assertNotContains('emphasis_open', $modes, 255 'Cyrillic-surrounded `_` must not emphasize'); 256 } 257 258 /** 259 * Positive: when the surrounding non-word context is whitespace or 260 * punctuation, multibyte content *inside* the emphasis span is fine. 261 * `_für etwas_` surrounded by spaces should emphasize the multibyte text. 262 */ 263 public function testMultibyteContentInsideEmphasisWorks() 264 { 265 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 266 $this->P->parse('foo _für etwas_ bar'); 267 268 $modes = array_column($this->H->calls, 0); 269 $this->assertContains('emphasis_open', $modes, 270 'Multibyte text inside `_..._` must emphasize when boundaries are clear'); 271 $this->assertContains('emphasis_close', $modes, 272 'Multibyte text inside `_..._` must emphasize when boundaries are clear'); 273 } 274 275 public function testRejectedOpenerBeforeValidSpanStaysLiteral() 276 { 277 $this->P->addMode('gfm_emphasis_underscore', new GfmEmphasisUnderscore()); 278 $this->P->parse('_ foo _bar_'); 279 $this->assertContains('emphasis_open', array_column($this->H->calls, 0)); 280 $this->assertStringContainsString('_ foo ', $this->H->calls[2][1][0]); 281 } 282} 283