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