xref: /dokuwiki/_test/tests/Parsing/ParserMode/FormattingTest.php (revision fe58309edafb067d90f3f40ef3d416100d558a04)
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