1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\ParserMode\Deleted; 6use dokuwiki\Parsing\ParserMode\Emphasis; 7use dokuwiki\Parsing\ParserMode\Internallink; 8use dokuwiki\Parsing\ParserMode\Monospace; 9use dokuwiki\Parsing\ParserMode\Strong; 10use dokuwiki\Parsing\ParserMode\Subscript; 11use dokuwiki\Parsing\ParserMode\Superscript; 12use dokuwiki\Parsing\ParserMode\Underline; 13 14/** 15 * Tests for the individual formatting modes (bold, italic, underline, etc.) 16 */ 17class FormattingTest extends ParserTestBase 18{ 19 function testStrong() 20 { 21 $this->P->addMode('strong', new Strong()); 22 $this->P->parse('Foo **Bar** Baz'); 23 $calls = [ 24 ['document_start', []], 25 ['p_open', []], 26 ['cdata', ["\nFoo "]], 27 ['strong_open', []], 28 ['cdata', ['Bar']], 29 ['strong_close', []], 30 ['cdata', [' Baz']], 31 ['p_close', []], 32 ['document_end', []], 33 ]; 34 $this->assertCalls($calls, $this->H->calls); 35 } 36 37 function testEmphasis() 38 { 39 $this->P->addMode('emphasis', new Emphasis()); 40 $this->P->parse('Foo //Bar// Baz'); 41 $calls = [ 42 ['document_start', []], 43 ['p_open', []], 44 ['cdata', ["\nFoo "]], 45 ['emphasis_open', []], 46 ['cdata', ['Bar']], 47 ['emphasis_close', []], 48 ['cdata', [' Baz']], 49 ['p_close', []], 50 ['document_end', []], 51 ]; 52 $this->assertCalls($calls, $this->H->calls); 53 } 54 55 function testUnderline() 56 { 57 $this->P->addMode('underline', new Underline()); 58 $this->P->parse('Foo __Bar__ Baz'); 59 $calls = [ 60 ['document_start', []], 61 ['p_open', []], 62 ['cdata', ["\nFoo "]], 63 ['underline_open', []], 64 ['cdata', ['Bar']], 65 ['underline_close', []], 66 ['cdata', [' Baz']], 67 ['p_close', []], 68 ['document_end', []], 69 ]; 70 $this->assertCalls($calls, $this->H->calls); 71 } 72 73 function testMonospace() 74 { 75 $this->P->addMode('monospace', new Monospace()); 76 $this->P->parse("Foo ''Bar'' Baz"); 77 $calls = [ 78 ['document_start', []], 79 ['p_open', []], 80 ['cdata', ["\nFoo "]], 81 ['monospace_open', []], 82 ['cdata', ['Bar']], 83 ['monospace_close', []], 84 ['cdata', [' Baz']], 85 ['p_close', []], 86 ['document_end', []], 87 ]; 88 $this->assertCalls($calls, $this->H->calls); 89 } 90 91 function testSubscript() 92 { 93 $this->P->addMode('subscript', new Subscript()); 94 $this->P->parse('Foo <sub>Bar</sub> Baz'); 95 $calls = [ 96 ['document_start', []], 97 ['p_open', []], 98 ['cdata', ["\nFoo "]], 99 ['subscript_open', []], 100 ['cdata', ['Bar']], 101 ['subscript_close', []], 102 ['cdata', [' Baz']], 103 ['p_close', []], 104 ['document_end', []], 105 ]; 106 $this->assertCalls($calls, $this->H->calls); 107 } 108 109 function testSuperscript() 110 { 111 $this->P->addMode('superscript', new Superscript()); 112 $this->P->parse('Foo <sup>Bar</sup> Baz'); 113 $calls = [ 114 ['document_start', []], 115 ['p_open', []], 116 ['cdata', ["\nFoo "]], 117 ['superscript_open', []], 118 ['cdata', ['Bar']], 119 ['superscript_close', []], 120 ['cdata', [' Baz']], 121 ['p_close', []], 122 ['document_end', []], 123 ]; 124 $this->assertCalls($calls, $this->H->calls); 125 } 126 127 function testDeleted() 128 { 129 $this->P->addMode('deleted', new Deleted()); 130 $this->P->parse('Foo <del>Bar</del> Baz'); 131 $calls = [ 132 ['document_start', []], 133 ['p_open', []], 134 ['cdata', ["\nFoo "]], 135 ['deleted_open', []], 136 ['cdata', ['Bar']], 137 ['deleted_close', []], 138 ['cdata', [' Baz']], 139 ['p_close', []], 140 ['document_end', []], 141 ]; 142 $this->assertCalls($calls, $this->H->calls); 143 } 144 145 function testNesting() 146 { 147 $this->P->addMode('strong', new Strong()); 148 $this->P->addMode('emphasis', new Emphasis()); 149 $this->P->parse('Foo **bold //and italic// text** Bar'); 150 $calls = [ 151 ['document_start', []], 152 ['p_open', []], 153 ['cdata', ["\nFoo "]], 154 ['strong_open', []], 155 ['cdata', ['bold ']], 156 ['emphasis_open', []], 157 ['cdata', ['and italic']], 158 ['emphasis_close', []], 159 ['cdata', [' text']], 160 ['strong_close', []], 161 ['cdata', [' Bar']], 162 ['p_close', []], 163 ['document_end', []], 164 ]; 165 $this->assertCalls($calls, $this->H->calls); 166 } 167 168 function testStrongClosesAfterLink() 169 { 170 // Regression: `**[[link]]**` must close Strong on the trailing `**`. 171 // Strong's exit pattern `(?<=[^\s])\*\*` needs to see the `]` that 172 // ends the link as the preceding non-whitespace char. The lexer 173 // passes the full subject + offset to PCRE so lookbehinds work 174 // across consumed tokens. 175 $this->P->addMode('strong', new Strong()); 176 $this->P->addMode('internallink', new Internallink()); 177 $this->P->parse('**[[wiki:x|link]]** bar'); 178 $calls = [ 179 ['document_start', []], 180 ['p_open', []], 181 ['cdata', ["\n"]], 182 ['strong_open', []], 183 ['internallink', ['wiki:x', 'link']], 184 ['strong_close', []], 185 ['cdata', [' bar']], 186 ['p_close', []], 187 ['document_end', []], 188 ]; 189 $this->assertCalls($calls, $this->H->calls); 190 } 191 192 function testStrongClosesAfterEmphasis() 193 { 194 // Regression: `**foo//bar//**` — after emphasis closes, Strong's 195 // closing `**` must still match; its lookbehind sees the `/` left 196 // behind by the emphasis exit. 197 $this->P->addMode('strong', new Strong()); 198 $this->P->addMode('emphasis', new Emphasis()); 199 $this->P->parse('**foo//bar//**'); 200 $calls = [ 201 ['document_start', []], 202 ['p_open', []], 203 ['cdata', ["\n"]], 204 ['strong_open', []], 205 ['cdata', ['foo']], 206 ['emphasis_open', []], 207 ['cdata', ['bar']], 208 ['emphasis_close', []], 209 ['strong_close', []], 210 ['cdata', ['']], 211 ['p_close', []], 212 ['document_end', []], 213 ]; 214 $this->assertCalls($calls, $this->H->calls); 215 } 216 217 function testNoSelfNesting() 218 { 219 // With flanking-aware Strong: an opener matches only if a valid 220 // closer exists (closer preceded by non-whitespace); a closer only 221 // fires at `**` preceded by non-whitespace. Here the inner `**`s 222 // are adjacent to spaces, so they can't close; the outermost `**` 223 // on the right is preceded by `d` and closes the outermost opener. 224 // Strong does not re-open inside itself. 225 $this->P->addMode('strong', new Strong()); 226 $this->P->parse('Foo **bold **not nested** end** Bar'); 227 $calls = [ 228 ['document_start', []], 229 ['p_open', []], 230 ['cdata', ["\nFoo "]], 231 ['strong_open', []], 232 ['cdata', ['bold **not nested']], 233 ['strong_close', []], 234 ['cdata', [' end** Bar']], 235 ['p_close', []], 236 ['document_end', []], 237 ]; 238 $this->assertCalls($calls, $this->H->calls); 239 } 240 241 /** 242 * @dataProvider provideParagraphBoundaryModes 243 * 244 * Formatting delimiters must not match across a blank line. An unclosed 245 * delimiter followed by a blank line and then an unrelated delimiter 246 * further down must stay literal — otherwise the lexer greedily swallows 247 * the paragraph break. 248 */ 249 function testDelimitersDoNotSpanParagraphBoundary( 250 string $modeName, 251 $mode, 252 string $input 253 ) { 254 $this->P->addMode($modeName, $mode); 255 $this->P->parse($input); 256 foreach ($this->H->calls as $call) { 257 $this->assertNotSame( 258 $modeName . '_open', 259 $call[0], 260 "Mode '$modeName' must not open across a blank line in: " . json_encode($input) 261 ); 262 } 263 } 264 265 public static function provideParagraphBoundaryModes(): array 266 { 267 return [ 268 'strong' => ['strong', new Strong(), "**open\n\nclose**"], 269 'emphasis' => ['emphasis', new Emphasis(), "//open\n\nclose//"], 270 'underline' => ['underline', new Underline(), "__open\n\nclose__"], 271 'monospace' => ['monospace', new Monospace(), "''open\n\nclose''"], 272 'subscript' => ['subscript', new Subscript(), "<sub>open\n\nclose</sub>"], 273 'superscript' => ['superscript', new Superscript(), "<sup>open\n\nclose</sup>"], 274 'deleted' => ['deleted', new Deleted(), "<del>open\n\nclose</del>"], 275 ]; 276 } 277 278 /** 279 * A single newline inside a delimiter pair is still valid (multi-line 280 * formatting), only blank lines end it. 281 */ 282 function testStrongAllowsSingleNewline() 283 { 284 $this->P->addMode('strong', new Strong()); 285 $this->P->parse("**open\nclose**"); 286 $this->assertContains( 287 'strong_open', 288 array_column($this->H->calls, 0), 289 'Strong must still match across a single newline' 290 ); 291 } 292 293 /** 294 * @dataProvider provideFlankingCases 295 * 296 * Flanking rules (simplified): an opening delimiter must be followed by 297 * a non-whitespace character, and a closing delimiter must be preceded 298 * by one. Empty delimiter pairs stay literal. 299 */ 300 function testFlankingRejectsInvalidDelimiters( 301 string $modeName, 302 $mode, 303 string $input 304 ) { 305 $this->P->addMode($modeName, $mode); 306 $this->P->parse($input); 307 foreach ($this->H->calls as $call) { 308 $this->assertNotSame( 309 $modeName . '_open', 310 $call[0], 311 "Mode '$modeName' must not open in: " . json_encode($input) 312 ); 313 } 314 } 315 316 public static function provideFlankingCases(): array 317 { 318 return [ 319 // Leading-whitespace opener 320 'strong-lead-ws' => ['strong', new Strong(), '** foo bar**'], 321 'emphasis-lead-ws' => ['emphasis', new Emphasis(), '// foo bar//'], 322 'underline-lead-ws' => ['underline', new Underline(), '__ foo bar__'], 323 'monospace-lead-ws' => ['monospace', new Monospace(), "'' foo bar''"], 324 'subscript-lead-ws' => ['subscript', new Subscript(), '<sub> foo bar</sub>'], 325 'superscript-lead-ws' => ['superscript', new Superscript(), '<sup> foo bar</sup>'], 326 'deleted-lead-ws' => ['deleted', new Deleted(), '<del> foo bar</del>'], 327 // Trailing-whitespace closer 328 'strong-trail-ws' => ['strong', new Strong(), '**foo bar **'], 329 'emphasis-trail-ws' => ['emphasis', new Emphasis(), '//foo bar //'], 330 'underline-trail-ws' => ['underline', new Underline(), '__foo bar __'], 331 'monospace-trail-ws' => ['monospace', new Monospace(), "''foo bar ''"], 332 'subscript-trail-ws' => ['subscript', new Subscript(), '<sub>foo bar </sub>'], 333 'superscript-trail-ws'=> ['superscript', new Superscript(), '<sup>foo bar </sup>'], 334 'deleted-trail-ws' => ['deleted', new Deleted(), '<del>foo bar </del>'], 335 // Empty delimiter pairs 336 'strong-empty' => ['strong', new Strong(), '**** stays literal'], 337 'underline-empty' => ['underline', new Underline(), '____ stays literal'], 338 'monospace-empty' => ['monospace', new Monospace(), "'''' stays literal"], 339 ]; 340 } 341 342 /** 343 * Single-character bodies still match, they're the smallest valid span. 344 */ 345 function testStrongSingleCharacterBody() 346 { 347 $this->P->addMode('strong', new Strong()); 348 $this->P->parse('**a**'); 349 $this->assertContains('strong_open', array_column($this->H->calls, 0)); 350 $this->assertContains('strong_close', array_column($this->H->calls, 0)); 351 } 352} 353