xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmQuoteTest.php (revision dccbd514161d242fdd8495ae23b9690bc52463c9)
1<?php
2
3namespace dokuwiki\test\Parsing\ParserMode;
4
5use dokuwiki\Parsing\ModeRegistry;
6use dokuwiki\Parsing\ParserMode\GfmQuote;
7use dokuwiki\Parsing\ParserMode\GfmTable;
8use dokuwiki\Parsing\ParserMode\Listblock;
9use dokuwiki\Parsing\ParserMode\Table;
10
11/**
12 * Tests for GFM-style block quotes.
13 *
14 * GfmQuote is the unified blockquote implementation covering both DW and
15 * GFM dialects. The mode captures the entire quote via addSpecialPattern
16 * and sub-parses the stripped body, so the outer parser only needs
17 * gfm_quote attached; inline modes and block modes (lists, code blocks,
18 * nested quotes) are picked up by the sub-parser.
19 *
20 * Two rendering shapes are exercised. Under DW-preferred syntax, a
21 * post-pass flattens the sub-parser's paragraph wrapping into linebreak-
22 * separated cdata so existing DW pages keep their `<br/>`-between-lines
23 * rendering. Under MD-preferred syntax the sub-parser's paragraph
24 * wrapping survives — a quote with one paragraph emits
25 * `<blockquote><p>...</p></blockquote>`.
26 */
27class GfmQuoteTest extends ParserTestBase
28{
29    public function tearDown(): void
30    {
31        ModeRegistry::reset();
32        parent::tearDown();
33    }
34
35    private function setSyntax(string $syntax): void
36    {
37        global $conf;
38        $conf['syntax'] = $syntax;
39        ModeRegistry::reset();
40    }
41
42    /**
43     * Recursively flatten call lists, descending into `nest` content.
44     * Useful for tests that just check whether an instruction appears
45     * somewhere in the rendered output regardless of nesting depth.
46     */
47    private function flatNames(array $calls): array
48    {
49        $names = [];
50        foreach ($calls as $call) {
51            $names[] = $call[0];
52            if ($call[0] === 'nest') {
53                $names = array_merge($names, $this->flatNames($call[1][0]));
54            }
55        }
56        return $names;
57    }
58
59    public function testSortValue()
60    {
61        $mode = new GfmQuote();
62        $this->assertSame(220, $mode->getSort());
63    }
64
65    // ----- DW-preferred rendering: linebreak-separated, no <p> ------------
66
67    public function testDwSingleLine()
68    {
69        $this->setSyntax('dw');
70        $this->P->addMode('gfm_quote', new GfmQuote());
71        $this->P->parse("> foo\n");
72
73        $expected = [
74            ['document_start', []],
75            ['quote_open', []],
76            ['nest', [[ ['cdata', ['foo']] ]]],
77            ['quote_close', []],
78            ['document_end', []],
79        ];
80        $this->assertCalls($expected, $this->H->calls);
81    }
82
83    public function testDwSpaceAfterMarkerOptional()
84    {
85        // GFM allows omitting the space after `>`; DW always did. Strip
86        // logic removes one optional space after the `>`, so `>foo` and
87        // `> foo` both produce cdata "foo".
88        $this->setSyntax('dw');
89        $this->P->addMode('gfm_quote', new GfmQuote());
90        $this->P->parse(">foo\n");
91
92        $names = $this->flatNames($this->H->calls);
93        $this->assertContains('quote_open', $names);
94        $this->assertContains('cdata', $names);
95    }
96
97    public function testDwTwoLinesEmitLinebreak()
98    {
99        // The DW-preferred post-pass converts the sub-parser's paragraph
100        // wrapping into a linebreak between the two cdata calls, matching
101        // the historical `<blockquote>foo<br/>bar</blockquote>` shape.
102        $this->setSyntax('dw');
103        $this->P->addMode('gfm_quote', new GfmQuote());
104        $this->P->parse("> foo\n> bar\n");
105
106        $expected = [
107            ['document_start', []],
108            ['quote_open', []],
109            ['nest', [[
110                ['cdata', ['foo']],
111                ['linebreak', []],
112                ['cdata', ['bar']],
113            ]]],
114            ['quote_close', []],
115            ['document_end', []],
116        ];
117        $this->assertCalls($expected, $this->H->calls);
118    }
119
120    public function testDwBlankMarkerLineEmitsTwoLinebreaks()
121    {
122        // `>` alone between content lines is a paragraph break in GFM.
123        // The DW post-pass replaces each p_open and each p_close with a
124        // linebreak, producing two adjacent linebreak calls between the
125        // two content cdata — matches the historical DW two-`<br/>` shape.
126        $this->setSyntax('dw');
127        $this->P->addMode('gfm_quote', new GfmQuote());
128        $this->P->parse("> foo\n>\n> bar\n");
129
130        $expected = [
131            ['document_start', []],
132            ['quote_open', []],
133            ['nest', [[
134                ['cdata', ['foo']],
135                ['linebreak', []],
136                ['linebreak', []],
137                ['cdata', ['bar']],
138            ]]],
139            ['quote_close', []],
140            ['document_end', []],
141        ];
142        $this->assertCalls($expected, $this->H->calls);
143    }
144
145    public function testDwNested()
146    {
147        $this->setSyntax('dw');
148        $this->P->addMode('gfm_quote', new GfmQuote());
149        $this->P->parse("> > foo\n");
150
151        // The outer captures a single line `> > foo`. Stripping the
152        // outer marker leaves `> foo`, which the sub-parser feeds back
153        // through GfmQuote — recursion produces a nested quote_open /
154        // quote_close pair carrying the cdata.
155        $names = $this->flatNames($this->H->calls);
156        $opens  = array_filter($names, static fn($n) => $n === 'quote_open');
157        $closes = array_filter($names, static fn($n) => $n === 'quote_close');
158        $this->assertCount(2, $opens, 'two levels of quote_open expected');
159        $this->assertCount(2, $closes, 'two levels of quote_close expected');
160    }
161
162    public function testDwNoLazyContinuation()
163    {
164        // GfmQuote does not implement lazy continuation: every quote
165        // line must begin with `>`. `bar` without a `>` prefix terminates
166        // the quote, so it ends up as a separate paragraph — matching
167        // today's DW behavior.
168        $this->setSyntax('dw');
169        $this->P->addMode('gfm_quote', new GfmQuote());
170        $this->P->parse("> foo\nbar\n");
171
172        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open');
173        $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line');
174
175        // `bar` is outside the quote — find a top-level cdata after the close
176        $afterClose = false;
177        $sawBarOutside = false;
178        foreach ($this->H->calls as $call) {
179            if ($call[0] === 'quote_close') $afterClose = true;
180            if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) {
181                $sawBarOutside = true;
182            }
183        }
184        $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote');
185    }
186
187    public function testDwBlankLineSeparatesQuotes()
188    {
189        // A truly blank line ends the quote. The next `>` starts a new
190        // quote, producing two distinct quote_open / quote_close pairs.
191        $this->setSyntax('dw');
192        $this->P->addMode('gfm_quote', new GfmQuote());
193        $this->P->parse("> foo\n\n> bar\n");
194
195        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open');
196        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close');
197        $this->assertCount(2, $opens, 'two distinct quote blocks');
198        $this->assertCount(2, $closes);
199    }
200
201    public function testDwHeaderInsideQuoteStaysCdata()
202    {
203        // Sub-parser excludes BASEONLY (Header / GfmHeader). Header
204        // instructions drive section-edit anchors and TOC entries that
205        // do not compose with `<blockquote>`. `# Foo` therefore stays
206        // as plain cdata text.
207        $this->setSyntax('dw');
208        $this->P->addMode('gfm_quote', new GfmQuote());
209        $this->P->parse("> # Foo\n");
210
211        $names = $this->flatNames($this->H->calls);
212        $this->assertNotContains('header', $names);
213        $this->assertNotContains('section_open', $names);
214        $this->assertContains('cdata', $names);
215    }
216
217    // ----- MD-preferred rendering: paragraph wrapping survives ------------
218
219    public function testMdSingleParagraph()
220    {
221        $this->setSyntax('md');
222        $this->P->addMode('gfm_quote', new GfmQuote());
223        $this->P->parse("> foo\n> bar\n");
224
225        // Sub-parser wraps the body in `p_open` / `p_close`. The outer
226        // wraps them inside a `nest`, and Block treats the nest as
227        // opaque. Two `>`-content lines join into one paragraph.
228        $expected = [
229            ['document_start', []],
230            ['quote_open', []],
231            ['nest', [[
232                ['p_open', []],
233                ['cdata', ["foo\nbar"]],
234                ['p_close', []],
235            ]]],
236            ['quote_close', []],
237            ['document_end', []],
238        ];
239        $this->assertCalls($expected, $this->H->calls);
240    }
241
242    public function testMdMultiParagraph()
243    {
244        // `>` alone between content lines creates two paragraphs in one
245        // blockquote — under MD-preferred the post-pass does not run, so
246        // the sub-parser's `p_open` / `p_close` pairs survive intact.
247        $this->setSyntax('md');
248        $this->P->addMode('gfm_quote', new GfmQuote());
249        $this->P->parse("> foo\n>\n> bar\n");
250
251        $names = $this->flatNames($this->H->calls);
252        $pOpens = array_filter($names, static fn($n) => $n === 'p_open');
253        $pCloses = array_filter($names, static fn($n) => $n === 'p_close');
254        $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote');
255        $this->assertCount(2, $pCloses);
256    }
257
258    // ----- Handoff from preceding block modes ----------------------------
259    //
260    // GfmTable, DW Table, and DW Listblock all consume the boundary \n on
261    // their way out (their exit pattern is \n by structural necessity: at
262    // the boundary there is no leading unmatched content for a zero-width
263    // lookahead exit to attach to). The pattern (?:^|\n)>... lets GfmQuote
264    // open at the line that starts the blockquote regardless of whether
265    // the preceding mode left the \n in the stream.
266
267    public function testHandoffFromGfmTable()
268    {
269        // Spec example 201: a `>` line immediately following a GFM table
270        // ends the table and opens a blockquote. GfmTable's exit consumes
271        // the boundary \n, so GfmQuote relies on the line-start (^)
272        // alternative to fire here.
273        $this->setSyntax('md');
274        $this->P->addMode('gfm_table', new GfmTable());
275        $this->P->addMode('gfm_quote', new GfmQuote());
276        $this->P->parse("| abc | def |\n| --- | --- |\n| bar | baz |\n> bar");
277
278        $names = array_map(static fn($c) => $c[0], $this->H->calls);
279        $this->assertContains('table_open', $names);
280        $this->assertContains('table_close', $names);
281        $this->assertContains('quote_open', $names);
282        $this->assertContains('quote_close', $names);
283
284        // Order: the quote must open after the table closes.
285        $tableCloseIdx = array_search('table_close', $names, true);
286        $quoteOpenIdx  = array_search('quote_open', $names, true);
287        $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx);
288    }
289
290    public function testHandoffFromDwTable()
291    {
292        // Same as the GFM-table case for a DW-style table. DW Table's
293        // exit also consumes \n, so the line-start (^) alternative is
294        // what lets the blockquote open.
295        $this->setSyntax('dw');
296        $this->P->addMode('table', new Table());
297        $this->P->addMode('gfm_quote', new GfmQuote());
298        $this->P->parse("| foo | bar |\n> baz");
299
300        $names = array_map(static fn($c) => $c[0], $this->H->calls);
301        $this->assertContains('table_open', $names);
302        $this->assertContains('table_close', $names);
303        $this->assertContains('quote_open', $names);
304        $this->assertContains('quote_close', $names);
305
306        $tableCloseIdx = array_search('table_close', $names, true);
307        $quoteOpenIdx  = array_search('quote_open', $names, true);
308        $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx);
309    }
310
311    public function testHandoffFromDwListblock()
312    {
313        // DW Listblock also exits on \n, consuming the boundary. The
314        // line-start alternative lets a `>` line right after the list
315        // open a blockquote without an intervening blank line.
316        $this->setSyntax('dw');
317        $this->P->addMode('listblock', new Listblock());
318        $this->P->addMode('gfm_quote', new GfmQuote());
319        $this->P->parse("  * foo\n  * bar\n> baz");
320
321        $names = array_map(static fn($c) => $c[0], $this->H->calls);
322        $this->assertContains('listu_open', $names);
323        $this->assertContains('listu_close', $names);
324        $this->assertContains('quote_open', $names);
325        $this->assertContains('quote_close', $names);
326
327        $listCloseIdx  = array_search('listu_close', $names, true);
328        $quoteOpenIdx  = array_search('quote_open', $names, true);
329        $this->assertGreaterThan($listCloseIdx, $quoteOpenIdx);
330    }
331
332    public function testMdListInsideQuote()
333    {
334        // GfmListblock is loaded under MD-preferred syntax, so a list
335        // inside a quote parses as a real list. The sub-parser's list
336        // calls land inside the outer `nest` wrapper.
337        $this->setSyntax('md');
338        ModeRegistry::reset();
339        // Add the registry's full mode set so gfm_listblock is reachable
340        // via the sub-parser (the sub-parser uses ModeRegistry::getModes,
341        // which honors $conf['syntax']).
342        foreach (ModeRegistry::getInstance()->getModes() as $m) {
343            $this->P->addMode($m['mode'], $m['obj']);
344        }
345
346        $this->P->parse("> - foo\n> - bar\n");
347
348        $names = $this->flatNames($this->H->calls);
349        $this->assertContains('quote_open', $names);
350        $this->assertContains('listu_open', $names, 'list inside quote must parse');
351        $this->assertContains('listu_close', $names);
352        $this->assertContains('quote_close', $names);
353    }
354}
355