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