1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\ParserMode\Deleted; 6use dokuwiki\Parsing\ParserMode\Emphasis; 7use dokuwiki\Parsing\ParserMode\Externallink; 8use dokuwiki\Parsing\ParserMode\Footnote; 9use dokuwiki\Parsing\ParserMode\Internallink; 10use dokuwiki\Parsing\ParserMode\Monospace; 11use dokuwiki\Parsing\ParserMode\Strong; 12use dokuwiki\Parsing\ParserMode\Subscript; 13use dokuwiki\Parsing\ParserMode\Superscript; 14use dokuwiki\Parsing\ParserMode\Underline; 15use dokuwiki\Parsing\ParserMode\Unformatted; 16 17/** 18 * Tests for the individual formatting modes (bold, italic, underline, etc.) 19 */ 20class FormattingTest extends ParserTestBase 21{ 22 public function testStrong() 23 { 24 $this->P->addMode('strong', new Strong()); 25 $this->P->parse('Foo **Bar** Baz'); 26 27 $calls = [ 28 ['document_start', []], 29 ['p_open', []], 30 ['cdata', ["\nFoo "]], 31 ['strong_open', []], 32 ['cdata', ['Bar']], 33 ['strong_close', []], 34 ['cdata', [' Baz']], 35 ['p_close', []], 36 ['document_end', []], 37 ]; 38 $this->assertCalls($calls, $this->H->calls); 39 } 40 41 public function testEmphasis() 42 { 43 $this->P->addMode('emphasis', new Emphasis()); 44 $this->P->parse('Foo //Bar// Baz'); 45 46 $calls = [ 47 ['document_start', []], 48 ['p_open', []], 49 ['cdata', ["\nFoo "]], 50 ['emphasis_open', []], 51 ['cdata', ['Bar']], 52 ['emphasis_close', []], 53 ['cdata', [' Baz']], 54 ['p_close', []], 55 ['document_end', []], 56 ]; 57 $this->assertCalls($calls, $this->H->calls); 58 } 59 60 public function testUnderline() 61 { 62 $this->P->addMode('underline', new Underline()); 63 $this->P->parse('Foo __Bar__ Baz'); 64 65 $calls = [ 66 ['document_start', []], 67 ['p_open', []], 68 ['cdata', ["\nFoo "]], 69 ['underline_open', []], 70 ['cdata', ['Bar']], 71 ['underline_close', []], 72 ['cdata', [' Baz']], 73 ['p_close', []], 74 ['document_end', []], 75 ]; 76 $this->assertCalls($calls, $this->H->calls); 77 } 78 79 public function testMonospace() 80 { 81 $this->P->addMode('monospace', new Monospace()); 82 $this->P->parse("Foo ''Bar'' Baz"); 83 84 $calls = [ 85 ['document_start', []], 86 ['p_open', []], 87 ['cdata', ["\nFoo "]], 88 ['monospace_open', []], 89 ['cdata', ['Bar']], 90 ['monospace_close', []], 91 ['cdata', [' Baz']], 92 ['p_close', []], 93 ['document_end', []], 94 ]; 95 $this->assertCalls($calls, $this->H->calls); 96 } 97 98 public function testSubscript() 99 { 100 $this->P->addMode('subscript', new Subscript()); 101 $this->P->parse('Foo <sub>Bar</sub> Baz'); 102 103 $calls = [ 104 ['document_start', []], 105 ['p_open', []], 106 ['cdata', ["\nFoo "]], 107 ['subscript_open', []], 108 ['cdata', ['Bar']], 109 ['subscript_close', []], 110 ['cdata', [' Baz']], 111 ['p_close', []], 112 ['document_end', []], 113 ]; 114 $this->assertCalls($calls, $this->H->calls); 115 } 116 117 public function testSuperscript() 118 { 119 $this->P->addMode('superscript', new Superscript()); 120 $this->P->parse('Foo <sup>Bar</sup> Baz'); 121 122 $calls = [ 123 ['document_start', []], 124 ['p_open', []], 125 ['cdata', ["\nFoo "]], 126 ['superscript_open', []], 127 ['cdata', ['Bar']], 128 ['superscript_close', []], 129 ['cdata', [' Baz']], 130 ['p_close', []], 131 ['document_end', []], 132 ]; 133 $this->assertCalls($calls, $this->H->calls); 134 } 135 136 public function testDeleted() 137 { 138 $this->P->addMode('deleted', new Deleted()); 139 $this->P->parse('Foo <del>Bar</del> Baz'); 140 141 $calls = [ 142 ['document_start', []], 143 ['p_open', []], 144 ['cdata', ["\nFoo "]], 145 ['deleted_open', []], 146 ['cdata', ['Bar']], 147 ['deleted_close', []], 148 ['cdata', [' Baz']], 149 ['p_close', []], 150 ['document_end', []], 151 ]; 152 $this->assertCalls($calls, $this->H->calls); 153 } 154 155 public function testNesting() 156 { 157 $this->P->addMode('strong', new Strong()); 158 $this->P->addMode('emphasis', new Emphasis()); 159 $this->P->parse('Foo **bold //and italic// text** Bar'); 160 161 $calls = [ 162 ['document_start', []], 163 ['p_open', []], 164 ['cdata', ["\nFoo "]], 165 ['strong_open', []], 166 ['cdata', ['bold ']], 167 ['emphasis_open', []], 168 ['cdata', ['and italic']], 169 ['emphasis_close', []], 170 ['cdata', [' text']], 171 ['strong_close', []], 172 ['cdata', [' Bar']], 173 ['p_close', []], 174 ['document_end', []], 175 ]; 176 $this->assertCalls($calls, $this->H->calls); 177 } 178 179 public function testStrongClosesAfterLink() 180 { 181 // Regression: `**[[link]]**` must close Strong on the trailing `**`. 182 // Strong's exit pattern `(?<=[^\s])\*\*` needs to see the `]` that 183 // ends the link as the preceding non-whitespace char. The lexer 184 // passes the full subject + offset to PCRE so lookbehinds work 185 // across consumed tokens. 186 $this->P->addMode('strong', new Strong()); 187 $this->P->addMode('internallink', new Internallink()); 188 $this->P->parse('**[[wiki:x|link]]** bar'); 189 190 $calls = [ 191 ['document_start', []], 192 ['p_open', []], 193 ['cdata', ["\n"]], 194 ['strong_open', []], 195 ['internallink', ['wiki:x', 'link']], 196 ['strong_close', []], 197 ['cdata', [' bar']], 198 ['p_close', []], 199 ['document_end', []], 200 ]; 201 $this->assertCalls($calls, $this->H->calls); 202 } 203 204 public function testStrongClosesAfterEmphasis() 205 { 206 // Regression: `**foo//bar//**` — after emphasis closes, Strong's 207 // closing `**` must still match; its lookbehind sees the `/` left 208 // behind by the emphasis exit. 209 $this->P->addMode('strong', new Strong()); 210 $this->P->addMode('emphasis', new Emphasis()); 211 $this->P->parse('**foo//bar//**'); 212 213 $calls = [ 214 ['document_start', []], 215 ['p_open', []], 216 ['cdata', ["\n"]], 217 ['strong_open', []], 218 ['cdata', ['foo']], 219 ['emphasis_open', []], 220 ['cdata', ['bar']], 221 ['emphasis_close', []], 222 ['strong_close', []], 223 ['cdata', ['']], 224 ['p_close', []], 225 ['document_end', []], 226 ]; 227 $this->assertCalls($calls, $this->H->calls); 228 } 229 230 public function testNoSelfNesting() 231 { 232 // With flanking-aware Strong: an opener matches only if a valid 233 // closer exists (closer preceded by non-whitespace); a closer only 234 // fires at `**` preceded by non-whitespace. Here the inner `**`s 235 // are adjacent to spaces, so they can't close; the outermost `**` 236 // on the right is preceded by `d` and closes the outermost opener. 237 // Strong does not re-open inside itself. 238 $this->P->addMode('strong', new Strong()); 239 $this->P->parse('Foo **bold **not nested** end** Bar'); 240 241 $calls = [ 242 ['document_start', []], 243 ['p_open', []], 244 ['cdata', ["\nFoo "]], 245 ['strong_open', []], 246 ['cdata', ['bold **not nested']], 247 ['strong_close', []], 248 ['cdata', [' end** Bar']], 249 ['p_close', []], 250 ['document_end', []], 251 ]; 252 $this->assertCalls($calls, $this->H->calls); 253 } 254 255 /** 256 * @dataProvider provideParagraphBoundaryModes 257 * 258 * Formatting delimiters must not match across a blank line. An unclosed 259 * delimiter followed by a blank line and then an unrelated delimiter 260 * further down must stay literal — otherwise the lexer greedily swallows 261 * the paragraph break. 262 */ 263 public function testDelimitersDoNotSpanParagraphBoundary( 264 string $modeName, 265 $mode, 266 string $input 267 ) { 268 $this->P->addMode($modeName, $mode); 269 $this->P->parse($input); 270 foreach ($this->H->calls as $call) { 271 $this->assertNotSame( 272 $modeName . '_open', 273 $call[0], 274 "Mode '$modeName' must not open across a blank line in: " . json_encode($input) 275 ); 276 } 277 } 278 279 public static function provideParagraphBoundaryModes(): array 280 { 281 return [ 282 'strong' => ['strong', new Strong(), "**open\n\nclose**"], 283 'emphasis' => ['emphasis', new Emphasis(), "//open\n\nclose//"], 284 'underline' => ['underline', new Underline(), "__open\n\nclose__"], 285 'monospace' => ['monospace', new Monospace(), "''open\n\nclose''"], 286 'subscript' => ['subscript', new Subscript(), "<sub>open\n\nclose</sub>"], 287 'superscript' => ['superscript', new Superscript(), "<sup>open\n\nclose</sup>"], 288 'deleted' => ['deleted', new Deleted(), "<del>open\n\nclose</del>"], 289 ]; 290 } 291 292 /** 293 * A single newline inside a delimiter pair is still valid (multi-line 294 * formatting), only blank lines end it. 295 */ 296 public function testStrongAllowsSingleNewline() 297 { 298 $this->P->addMode('strong', new Strong()); 299 $this->P->parse("**open\nclose**"); 300 $this->assertContains( 301 'strong_open', 302 array_column($this->H->calls, 0), 303 'Strong must still match across a single newline' 304 ); 305 } 306 307 /** 308 * @dataProvider provideFlankingCases 309 * 310 * Flanking rules (simplified): an opening delimiter must be followed by 311 * a non-whitespace character, and a closing delimiter must be preceded 312 * by one. Empty delimiter pairs stay literal. 313 */ 314 public function testFlankingRejectsInvalidDelimiters( 315 string $modeName, 316 $mode, 317 string $input 318 ) { 319 $this->P->addMode($modeName, $mode); 320 $this->P->parse($input); 321 foreach ($this->H->calls as $call) { 322 $this->assertNotSame( 323 $modeName . '_open', 324 $call[0], 325 "Mode '$modeName' must not open in: " . json_encode($input) 326 ); 327 } 328 } 329 330 public static function provideFlankingCases(): array 331 { 332 return [ 333 // Leading-whitespace opener 334 'strong-lead-ws' => ['strong', new Strong(), '** foo bar**'], 335 'emphasis-lead-ws' => ['emphasis', new Emphasis(), '// foo bar//'], 336 'underline-lead-ws' => ['underline', new Underline(), '__ foo bar__'], 337 'monospace-lead-ws' => ['monospace', new Monospace(), "'' foo bar''"], 338 'subscript-lead-ws' => ['subscript', new Subscript(), '<sub> foo bar</sub>'], 339 'superscript-lead-ws' => ['superscript', new Superscript(), '<sup> foo bar</sup>'], 340 'deleted-lead-ws' => ['deleted', new Deleted(), '<del> foo bar</del>'], 341 // Trailing-whitespace closer 342 'strong-trail-ws' => ['strong', new Strong(), '**foo bar **'], 343 'emphasis-trail-ws' => ['emphasis', new Emphasis(), '//foo bar //'], 344 'underline-trail-ws' => ['underline', new Underline(), '__foo bar __'], 345 'monospace-trail-ws' => ['monospace', new Monospace(), "''foo bar ''"], 346 'subscript-trail-ws' => ['subscript', new Subscript(), '<sub>foo bar </sub>'], 347 'superscript-trail-ws'=> ['superscript', new Superscript(), '<sup>foo bar </sup>'], 348 'deleted-trail-ws' => ['deleted', new Deleted(), '<del>foo bar </del>'], 349 // Empty delimiter pairs 350 'strong-empty' => ['strong', new Strong(), '**** stays literal'], 351 'underline-empty' => ['underline', new Underline(), '____ stays literal'], 352 'monospace-empty' => ['monospace', new Monospace(), "'''' stays literal"], 353 ]; 354 } 355 356 /** 357 * Single-character bodies still match, they're the smallest valid span. 358 */ 359 public function testStrongSingleCharacterBody() 360 { 361 $this->P->addMode('strong', new Strong()); 362 $this->P->parse('**a**'); 363 $this->assertContains('strong_open', array_column($this->H->calls, 0)); 364 $this->assertContains('strong_close', array_column($this->H->calls, 0)); 365 } 366 367 /** 368 * An opener without a valid closer must stay literal while a later 369 * valid span in the same paragraph still matches. The closer scan 370 * rejects the first candidate (its only potential closer is preceded 371 * by whitespace, or is the very opener of the valid span) without 372 * blocking the second. 373 * 374 * @dataProvider provideRejectedOpenerBeforeValidSpan 375 */ 376 public function testRejectedOpenerBeforeValidSpanStaysLiteral( 377 string $modeName, 378 $mode, 379 string $input, 380 string $openInstruction 381 ) { 382 $this->P->addMode($modeName, $mode); 383 $this->P->parse($input); 384 385 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 386 $this->assertContains([$openInstruction, []], $calls); 387 $this->assertContains(['cdata', ['bar']], $calls); 388 $this->assertStringContainsString(' foo ', $this->H->calls[2][1][0]); 389 } 390 391 public static function provideRejectedOpenerBeforeValidSpan(): array 392 { 393 return [ 394 'strong' => ['strong', new Strong(), '** foo **bar**', 'strong_open'], 395 'emphasis' => ['emphasis', new Emphasis(), '// foo //bar//', 'emphasis_open'], 396 'underline' => ['underline', new Underline(), '__ foo __bar__', 'underline_open'], 397 'monospace' => ['monospace', new Monospace(), "'' foo ''bar''", 'monospace_open'], 398 'deleted' => ['deleted', new Deleted(), '<del> foo <del>bar</del>', 'deleted_open'], 399 'subscript' => ['subscript', new Subscript(), '<sub> foo <sub>bar</sub>', 'subscript_open'], 400 'superscript' => ['superscript', new Superscript(), '<sup> foo <sup>bar</sup>', 'superscript_open'], 401 ]; 402 } 403 404 /** 405 * A closer in the next paragraph must not validate an opener in the 406 * current one, and the paragraph after a rejected opener must parse 407 * normally (the closer-free memo range ends at the paragraph break). 408 */ 409 public function testRejectedOpenerDoesNotAffectNextParagraph() 410 { 411 $this->P->addMode('strong', new Strong()); 412 $this->P->parse("**no closer here\n\n**closed** here"); 413 414 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 415 $this->assertContains(['cdata', ["\n**no closer here\n\n"]], $calls); 416 $this->assertContains(['strong_open', []], $calls); 417 $this->assertContains(['cdata', ['closed']], $calls); 418 } 419 420 /** 421 * There is no length limit on a formatting span within a paragraph: 422 * real pages contain styled spans well beyond 32KB, which must not 423 * degrade to literal text. This is what caps the closer scan cannot 424 * provide (a PCRE counted repeat maxes out at 65535) and why the scan 425 * runs as a memoized lexer closer pattern instead. 426 */ 427 public function testVeryLongSpanIsRecognized() 428 { 429 $body = trim(str_repeat('some words ', 7000)); // ~77KB 430 $this->P->addMode('strong', new Strong()); 431 $this->P->parse('**' . $body . '**'); 432 433 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 434 $this->assertContains(['strong_open', []], $calls); 435 $this->assertContains(['cdata', [$body]], $calls); 436 } 437 438 /** 439 * Delimiter-dense input without any closer must stay literal — and, 440 * although a test can't assert wall clock, it exercises the memoized 441 * closer-free range that keeps this shape linear instead of scanning 442 * the paragraph once per opener. 443 */ 444 public function testManyOpenersWithoutCloserStayLiteral() 445 { 446 $text = trim(str_repeat('**a ', 500)); 447 $this->P->addMode('strong', new Strong()); 448 $this->P->parse($text); 449 450 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 451 $this->assertNotContains(['strong_open', []], $calls); 452 $this->assertContains(['cdata', ["\n" . $text]], $calls); 453 } 454 455 /** 456 * An inner delimiter must not pair with one in a following sibling span: 457 * its closer lies beyond the enclosing mode's closer, so it can never 458 * close within the parent and must stay literal. Here the `//` inside the 459 * first `''…''` would otherwise reach the `//` of the second, dragging the 460 * monospace boundary along with it. 461 */ 462 public function testInnerDelimiterDoesNotSpanEnclosingCloser() 463 { 464 $this->P->addMode('monospace', new Monospace()); 465 $this->P->addMode('emphasis', new Emphasis()); 466 $this->P->parse("''a//b'', ''c//d''"); 467 468 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 469 $this->assertNotContains(['emphasis_open', []], $calls); 470 $this->assertContains(['cdata', ['a//b']], $calls); 471 $this->assertContains(['cdata', ['c//d']], $calls); 472 } 473 474 /** 475 * The counterpart to the above: an inner delimiter whose closer does sit 476 * inside the enclosing span still nests normally. 477 */ 478 public function testInnerDelimiterClosingWithinEnclosingSpanNests() 479 { 480 $this->P->addMode('monospace', new Monospace()); 481 $this->P->addMode('emphasis', new Emphasis()); 482 $this->P->parse("''a//b//c''"); 483 484 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 485 $this->assertContains(['monospace_open', []], $calls); 486 $this->assertContains(['emphasis_open', []], $calls); 487 $this->assertContains(['cdata', ['b']], $calls); 488 } 489 490 /** 491 * The enclosing closer may directly follow the inner opener. The `//` 492 * in the first `''…''` sits right before the closing `''`, so it can 493 * only pair with the `//` of the second span — it must stay literal 494 * even though no character separates it from the monospace closer. 495 */ 496 public function testInnerDelimiterAdjacentToEnclosingCloserStaysLiteral() 497 { 498 $this->P->addMode('monospace', new Monospace()); 499 $this->P->addMode('emphasis', new Emphasis()); 500 $this->P->parse("''//'', ''a c//d''"); 501 502 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 503 $this->assertNotContains(['emphasis_open', []], $calls); 504 $this->assertContains(['cdata', ['//']], $calls); 505 $this->assertContains(['cdata', ['a c//d']], $calls); 506 } 507 508 /** 509 * A closer lookalike inside protected content does not count against a 510 * nested delimiter: the `''` inside the nowiki span never closes the 511 * monospace mode, so the emphasis pair around it still nests. 512 */ 513 public function testFakeEnclosingCloserInProtectedContentStillNests() 514 { 515 $this->P->addMode('monospace', new Monospace()); 516 $this->P->addMode('emphasis', new Emphasis()); 517 $this->P->addMode('unformatted', new Unformatted()); 518 $this->P->parse("''a //b <nowiki>x''y</nowiki> c// d''"); 519 520 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 521 $this->assertContains(['emphasis_open', []], $calls); 522 $this->assertContains(['unformatted', ["x''y"]], $calls); 523 $this->assertContains(['cdata', [' d']], $calls); 524 } 525 526 /** 527 * A closer lookalike inside protected content does not validate an 528 * opener either: the only `''` after the candidate lies in a nowiki 529 * span the lexer will consume verbatim, so monospace never opens. 530 */ 531 public function testFakeCloserInProtectedContentDoesNotOpenFormatting() 532 { 533 $this->P->addMode('monospace', new Monospace()); 534 $this->P->addMode('unformatted', new Unformatted()); 535 $this->P->parse("x ''a <nowiki>b''</nowiki> c"); 536 537 $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); 538 $this->assertNotContains(['monospace_open', []], $calls); 539 $this->assertContains(['unformatted', ["b''"]], $calls); 540 } 541 542 /** 543 * A guarded formatting mode may enclose an unguarded mode (a footnote) 544 * that itself contains formatting. The enclosing-closer check must look 545 * past the unguarded footnote to the strong span: the inner // has its 546 * only closer beyond the strong closer, so it stays literal instead of 547 * opening inside the footnote and pairing across the footnote and strong 548 * boundaries (which left strong unclosed). 549 */ 550 public function testFormattingInsideFootnoteDoesNotPairAcrossEnclosingStrong() 551 { 552 $this->P->addMode('strong', new Strong()); 553 $this->P->addMode('emphasis', new Emphasis()); 554 $this->P->addMode('footnote', new Footnote()); 555 $this->P->parse('**b ((n //x)) c** and //y//'); 556 557 $names = array_column($this->flattenCalls($this->H->calls), 0); 558 $counts = array_count_values($names); 559 560 // emphasis opens once, for the trailing //y// only, not inside the footnote 561 $this->assertSame(1, $counts['emphasis_open'] ?? 0); 562 // strong is closed properly rather than left dangling 563 $this->assertArrayHasKey('strong_close', $counts); 564 } 565 566 /** 567 * A // preceded by a colon is a valid emphasis closer, so the span in 568 * //identifier:// closes at that delimiter and the following **label** 569 * is emitted as strong. 570 */ 571 public function testEmphasisClosesAtColonPrecededDelimiter() 572 { 573 $this->P->addMode('strong', new Strong()); 574 $this->P->addMode('emphasis', new Emphasis()); 575 $this->P->addMode('externallink', new Externallink()); 576 $this->P->parse('//identifier:// **label**'); 577 578 $calls = [ 579 ['document_start', []], 580 ['p_open', []], 581 ['cdata', ["\n"]], 582 ['emphasis_open', []], 583 ['cdata', ['identifier:']], 584 ['emphasis_close', []], 585 ['cdata', [' ']], 586 ['strong_open', []], 587 ['cdata', ['label']], 588 ['strong_close', []], 589 ['cdata', ['']], 590 ['p_close', []], 591 ['document_end', []], 592 ]; 593 $this->assertCalls($calls, $this->H->calls); 594 } 595 596 /** 597 * A recognized URL scheme is consumed as a link token whose content the 598 * closer scan never looks into, so the // in http:// cannot close the 599 * span. Emphasis wraps the whole run and closes at the trailing //. 600 */ 601 public function testEmphasisWrapsRecognizedUrl() 602 { 603 $this->P->addMode('strong', new Strong()); 604 $this->P->addMode('emphasis', new Emphasis()); 605 $this->P->addMode('externallink', new Externallink()); 606 $this->P->parse('//italic known url http://foo.com/bar is here//'); 607 608 $calls = [ 609 ['document_start', []], 610 ['p_open', []], 611 ['cdata', ["\n"]], 612 ['emphasis_open', []], 613 ['cdata', ['italic known url ']], 614 ['externallink', ['http://foo.com/bar', null]], 615 ['cdata', [' is here']], 616 ['emphasis_close', []], 617 ['cdata', ['']], 618 ['p_close', []], 619 ['document_end', []], 620 ]; 621 $this->assertCalls($calls, $this->H->calls); 622 } 623 624 /** 625 * An unrecognized scheme is plain text and gets no special guarding: the 626 * // in proto:// closes emphasis like any other delimiter, and the text 627 * past that closer stays literal. 628 */ 629 public function testUnrecognizedSchemeClosesEmphasis() 630 { 631 $this->P->addMode('strong', new Strong()); 632 $this->P->addMode('emphasis', new Emphasis()); 633 $this->P->addMode('externallink', new Externallink()); 634 $this->P->parse('//italic unknown url proto://foo.com/bar is here//'); 635 636 $calls = [ 637 ['document_start', []], 638 ['p_open', []], 639 ['cdata', ["\n"]], 640 ['emphasis_open', []], 641 ['cdata', ['italic unknown url proto:']], 642 ['emphasis_close', []], 643 ['cdata', ['foo.com/bar is here//']], 644 ['p_close', []], 645 ['document_end', []], 646 ]; 647 $this->assertCalls($calls, $this->H->calls); 648 } 649 650 /** 651 * A recognized URL greedily consumes the trailing //, so no closer 652 * remains before the paragraph break. The opener therefore stays literal 653 * rather than opening a span that could never close. 654 */ 655 public function testRecognizedUrlEatingTrailingCloserLeavesOpenerLiteral() 656 { 657 $this->P->addMode('strong', new Strong()); 658 $this->P->addMode('emphasis', new Emphasis()); 659 $this->P->addMode('externallink', new Externallink()); 660 $this->P->parse('//italic known url http://foo.com/bar//'); 661 662 $calls = [ 663 ['document_start', []], 664 ['p_open', []], 665 ['cdata', ["\n//italic known url "]], 666 ['externallink', ['http://foo.com/bar//', null]], 667 ['cdata', ['']], 668 ['p_close', []], 669 ['document_end', []], 670 ]; 671 $this->assertCalls($calls, $this->H->calls); 672 } 673 674 /** 675 * An unrecognized scheme's // closes emphasis at proto://; the trailing 676 * // has nothing after it to open a new span and stays literal. 677 */ 678 public function testUnrecognizedSchemeClosesEmphasisWithLiteralTail() 679 { 680 $this->P->addMode('strong', new Strong()); 681 $this->P->addMode('emphasis', new Emphasis()); 682 $this->P->addMode('externallink', new Externallink()); 683 $this->P->parse('//italic unknown url proto://foo.com/bar//'); 684 685 $calls = [ 686 ['document_start', []], 687 ['p_open', []], 688 ['cdata', ["\n"]], 689 ['emphasis_open', []], 690 ['cdata', ['italic unknown url proto:']], 691 ['emphasis_close', []], 692 ['cdata', ['foo.com/bar//']], 693 ['p_close', []], 694 ['document_end', []], 695 ]; 696 $this->assertCalls($calls, $this->H->calls); 697 } 698 699 /** 700 * Flatten a handler call list, inlining the calls a footnote buffers in 701 * its nest rewrite, into a list of [name, args] pairs. 702 * 703 * @param array $calls 704 * @return array[] 705 */ 706 private function flattenCalls(array $calls): array 707 { 708 $out = []; 709 foreach ($calls as $call) { 710 if ($call[0] === 'nest') { 711 $out = array_merge($out, $this->flattenCalls($call[1][0])); 712 } else { 713 $out[] = [$call[0], $call[1]]; 714 } 715 } 716 return $out; 717 } 718} 719