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