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