xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmQuoteTest.php (revision 47a02a102092be9e1e6f1ddaf158bdfffdb13d4f)
1309a0852SAndreas Gohr<?php
2309a0852SAndreas Gohr
3309a0852SAndreas Gohrnamespace dokuwiki\test\Parsing\ParserMode;
4309a0852SAndreas Gohr
5309a0852SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmQuote;
6dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmTable;
7dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\Listblock;
8dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\Table;
9309a0852SAndreas Gohr
10309a0852SAndreas Gohr/**
11309a0852SAndreas Gohr * Tests for GFM-style block quotes.
12309a0852SAndreas Gohr *
13309a0852SAndreas Gohr * GfmQuote is the unified blockquote implementation covering both DW and
14309a0852SAndreas Gohr * GFM dialects. The mode captures the entire quote via addSpecialPattern
15309a0852SAndreas Gohr * and sub-parses the stripped body, so the outer parser only needs
16309a0852SAndreas Gohr * gfm_quote attached; inline modes and block modes (lists, code blocks,
17309a0852SAndreas Gohr * nested quotes) are picked up by the sub-parser.
18309a0852SAndreas Gohr *
19309a0852SAndreas Gohr * Two rendering shapes are exercised. Under DW-preferred syntax, a
20309a0852SAndreas Gohr * post-pass flattens the sub-parser's paragraph wrapping into linebreak-
21309a0852SAndreas Gohr * separated cdata so existing DW pages keep their `<br/>`-between-lines
22309a0852SAndreas Gohr * rendering. Under MD-preferred syntax the sub-parser's paragraph
23309a0852SAndreas Gohr * wrapping survives — a quote with one paragraph emits
24309a0852SAndreas Gohr * `<blockquote><p>...</p></blockquote>`.
25309a0852SAndreas Gohr */
26309a0852SAndreas Gohrclass GfmQuoteTest extends ParserTestBase
27309a0852SAndreas Gohr{
28309a0852SAndreas Gohr    /**
29309a0852SAndreas Gohr     * Recursively flatten call lists, descending into `nest` content.
30309a0852SAndreas Gohr     * Useful for tests that just check whether an instruction appears
31309a0852SAndreas Gohr     * somewhere in the rendered output regardless of nesting depth.
32309a0852SAndreas Gohr     */
33309a0852SAndreas Gohr    private function flatNames(array $calls): array
34309a0852SAndreas Gohr    {
35309a0852SAndreas Gohr        $names = [];
36309a0852SAndreas Gohr        foreach ($calls as $call) {
37309a0852SAndreas Gohr            $names[] = $call[0];
38309a0852SAndreas Gohr            if ($call[0] === 'nest') {
39309a0852SAndreas Gohr                $names = array_merge($names, $this->flatNames($call[1][0]));
40309a0852SAndreas Gohr            }
41309a0852SAndreas Gohr        }
42309a0852SAndreas Gohr        return $names;
43309a0852SAndreas Gohr    }
44309a0852SAndreas Gohr
45309a0852SAndreas Gohr    public function testSortValue()
46309a0852SAndreas Gohr    {
47309a0852SAndreas Gohr        $mode = new GfmQuote();
48309a0852SAndreas Gohr        $this->assertSame(220, $mode->getSort());
49309a0852SAndreas Gohr    }
50309a0852SAndreas Gohr
51309a0852SAndreas Gohr    // ----- DW-preferred rendering: linebreak-separated, no <p> ------------
52309a0852SAndreas Gohr
53309a0852SAndreas Gohr    public function testDwSingleLine()
54309a0852SAndreas Gohr    {
5513a62f81SAndreas Gohr        $this->setSyntax('dw');
56309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
57309a0852SAndreas Gohr        $this->P->parse("> foo\n");
58309a0852SAndreas Gohr
59309a0852SAndreas Gohr        $expected = [
60309a0852SAndreas Gohr            ['document_start', []],
61309a0852SAndreas Gohr            ['quote_open', []],
62309a0852SAndreas Gohr            ['nest', [[ ['cdata', ['foo']] ]]],
63309a0852SAndreas Gohr            ['quote_close', []],
64309a0852SAndreas Gohr            ['document_end', []],
65309a0852SAndreas Gohr        ];
66309a0852SAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
67309a0852SAndreas Gohr    }
68309a0852SAndreas Gohr
69309a0852SAndreas Gohr    public function testDwSpaceAfterMarkerOptional()
70309a0852SAndreas Gohr    {
71309a0852SAndreas Gohr        // GFM allows omitting the space after `>`; DW always did. Strip
72309a0852SAndreas Gohr        // logic removes one optional space after the `>`, so `>foo` and
73309a0852SAndreas Gohr        // `> foo` both produce cdata "foo".
7413a62f81SAndreas Gohr        $this->setSyntax('dw');
75309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
76309a0852SAndreas Gohr        $this->P->parse(">foo\n");
77309a0852SAndreas Gohr
78309a0852SAndreas Gohr        $names = $this->flatNames($this->H->calls);
79309a0852SAndreas Gohr        $this->assertContains('quote_open', $names);
80309a0852SAndreas Gohr        $this->assertContains('cdata', $names);
81309a0852SAndreas Gohr    }
82309a0852SAndreas Gohr
83309a0852SAndreas Gohr    public function testDwTwoLinesEmitLinebreak()
84309a0852SAndreas Gohr    {
85309a0852SAndreas Gohr        // The DW-preferred post-pass converts the sub-parser's paragraph
86309a0852SAndreas Gohr        // wrapping into a linebreak between the two cdata calls, matching
87309a0852SAndreas Gohr        // the historical `<blockquote>foo<br/>bar</blockquote>` shape.
8813a62f81SAndreas Gohr        $this->setSyntax('dw');
89309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
90309a0852SAndreas Gohr        $this->P->parse("> foo\n> bar\n");
91309a0852SAndreas Gohr
92309a0852SAndreas Gohr        $expected = [
93309a0852SAndreas Gohr            ['document_start', []],
94309a0852SAndreas Gohr            ['quote_open', []],
95309a0852SAndreas Gohr            ['nest', [[
96309a0852SAndreas Gohr                ['cdata', ['foo']],
97309a0852SAndreas Gohr                ['linebreak', []],
98309a0852SAndreas Gohr                ['cdata', ['bar']],
99309a0852SAndreas Gohr            ]]],
100309a0852SAndreas Gohr            ['quote_close', []],
101309a0852SAndreas Gohr            ['document_end', []],
102309a0852SAndreas Gohr        ];
103309a0852SAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
104309a0852SAndreas Gohr    }
105309a0852SAndreas Gohr
106309a0852SAndreas Gohr    public function testDwBlankMarkerLineEmitsTwoLinebreaks()
107309a0852SAndreas Gohr    {
108309a0852SAndreas Gohr        // `>` alone between content lines is a paragraph break in GFM.
109309a0852SAndreas Gohr        // The DW post-pass replaces each p_open and each p_close with a
110309a0852SAndreas Gohr        // linebreak, producing two adjacent linebreak calls between the
111309a0852SAndreas Gohr        // two content cdata — matches the historical DW two-`<br/>` shape.
11213a62f81SAndreas Gohr        $this->setSyntax('dw');
113309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
114309a0852SAndreas Gohr        $this->P->parse("> foo\n>\n> bar\n");
115309a0852SAndreas Gohr
116309a0852SAndreas Gohr        $expected = [
117309a0852SAndreas Gohr            ['document_start', []],
118309a0852SAndreas Gohr            ['quote_open', []],
119309a0852SAndreas Gohr            ['nest', [[
120309a0852SAndreas Gohr                ['cdata', ['foo']],
121309a0852SAndreas Gohr                ['linebreak', []],
122309a0852SAndreas Gohr                ['linebreak', []],
123309a0852SAndreas Gohr                ['cdata', ['bar']],
124309a0852SAndreas Gohr            ]]],
125309a0852SAndreas Gohr            ['quote_close', []],
126309a0852SAndreas Gohr            ['document_end', []],
127309a0852SAndreas Gohr        ];
128309a0852SAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
129309a0852SAndreas Gohr    }
130309a0852SAndreas Gohr
131309a0852SAndreas Gohr    public function testDwNested()
132309a0852SAndreas Gohr    {
13313a62f81SAndreas Gohr        $this->setSyntax('dw');
134309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
135309a0852SAndreas Gohr        $this->P->parse("> > foo\n");
136309a0852SAndreas Gohr
137309a0852SAndreas Gohr        // The outer captures a single line `> > foo`. Stripping the
138309a0852SAndreas Gohr        // outer marker leaves `> foo`, which the sub-parser feeds back
139309a0852SAndreas Gohr        // through GfmQuote — recursion produces a nested quote_open /
140309a0852SAndreas Gohr        // quote_close pair carrying the cdata.
141309a0852SAndreas Gohr        $names = $this->flatNames($this->H->calls);
142309a0852SAndreas Gohr        $opens  = array_filter($names, static fn($n) => $n === 'quote_open');
143309a0852SAndreas Gohr        $closes = array_filter($names, static fn($n) => $n === 'quote_close');
144309a0852SAndreas Gohr        $this->assertCount(2, $opens, 'two levels of quote_open expected');
145309a0852SAndreas Gohr        $this->assertCount(2, $closes, 'two levels of quote_close expected');
146309a0852SAndreas Gohr    }
147309a0852SAndreas Gohr
148309a0852SAndreas Gohr    public function testDwNoLazyContinuation()
149309a0852SAndreas Gohr    {
150309a0852SAndreas Gohr        // GfmQuote does not implement lazy continuation: every quote
151309a0852SAndreas Gohr        // line must begin with `>`. `bar` without a `>` prefix terminates
152309a0852SAndreas Gohr        // the quote, so it ends up as a separate paragraph — matching
153309a0852SAndreas Gohr        // today's DW behavior.
15413a62f81SAndreas Gohr        $this->setSyntax('dw');
155309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
156309a0852SAndreas Gohr        $this->P->parse("> foo\nbar\n");
157309a0852SAndreas Gohr
158309a0852SAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open');
159309a0852SAndreas Gohr        $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line');
160309a0852SAndreas Gohr
161309a0852SAndreas Gohr        // `bar` is outside the quote — find a top-level cdata after the close
162309a0852SAndreas Gohr        $afterClose = false;
163309a0852SAndreas Gohr        $sawBarOutside = false;
164309a0852SAndreas Gohr        foreach ($this->H->calls as $call) {
165309a0852SAndreas Gohr            if ($call[0] === 'quote_close') $afterClose = true;
166309a0852SAndreas Gohr            if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) {
167309a0852SAndreas Gohr                $sawBarOutside = true;
168309a0852SAndreas Gohr            }
169309a0852SAndreas Gohr        }
170309a0852SAndreas Gohr        $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote');
171309a0852SAndreas Gohr    }
172309a0852SAndreas Gohr
173309a0852SAndreas Gohr    public function testDwBlankLineSeparatesQuotes()
174309a0852SAndreas Gohr    {
175309a0852SAndreas Gohr        // A truly blank line ends the quote. The next `>` starts a new
176309a0852SAndreas Gohr        // quote, producing two distinct quote_open / quote_close pairs.
17713a62f81SAndreas Gohr        $this->setSyntax('dw');
178309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
179309a0852SAndreas Gohr        $this->P->parse("> foo\n\n> bar\n");
180309a0852SAndreas Gohr
181309a0852SAndreas Gohr        $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open');
182309a0852SAndreas Gohr        $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close');
183309a0852SAndreas Gohr        $this->assertCount(2, $opens, 'two distinct quote blocks');
184309a0852SAndreas Gohr        $this->assertCount(2, $closes);
185309a0852SAndreas Gohr    }
186309a0852SAndreas Gohr
187309a0852SAndreas Gohr    public function testDwHeaderInsideQuoteStaysCdata()
188309a0852SAndreas Gohr    {
189309a0852SAndreas Gohr        // Sub-parser excludes BASEONLY (Header / GfmHeader). Header
190309a0852SAndreas Gohr        // instructions drive section-edit anchors and TOC entries that
191309a0852SAndreas Gohr        // do not compose with `<blockquote>`. `# Foo` therefore stays
192309a0852SAndreas Gohr        // as plain cdata text.
19313a62f81SAndreas Gohr        $this->setSyntax('dw');
194309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
195309a0852SAndreas Gohr        $this->P->parse("> # Foo\n");
196309a0852SAndreas Gohr
197309a0852SAndreas Gohr        $names = $this->flatNames($this->H->calls);
198309a0852SAndreas Gohr        $this->assertNotContains('header', $names);
199309a0852SAndreas Gohr        $this->assertNotContains('section_open', $names);
200309a0852SAndreas Gohr        $this->assertContains('cdata', $names);
201309a0852SAndreas Gohr    }
202309a0852SAndreas Gohr
203309a0852SAndreas Gohr    // ----- MD-preferred rendering: paragraph wrapping survives ------------
204309a0852SAndreas Gohr
205309a0852SAndreas Gohr    public function testMdSingleParagraph()
206309a0852SAndreas Gohr    {
20713a62f81SAndreas Gohr        $this->setSyntax('md');
208309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
209309a0852SAndreas Gohr        $this->P->parse("> foo\n> bar\n");
210309a0852SAndreas Gohr
211309a0852SAndreas Gohr        // Sub-parser wraps the body in `p_open` / `p_close`. The outer
212309a0852SAndreas Gohr        // wraps them inside a `nest`, and Block treats the nest as
213309a0852SAndreas Gohr        // opaque. Two `>`-content lines join into one paragraph.
214309a0852SAndreas Gohr        $expected = [
215309a0852SAndreas Gohr            ['document_start', []],
216309a0852SAndreas Gohr            ['quote_open', []],
217309a0852SAndreas Gohr            ['nest', [[
218309a0852SAndreas Gohr                ['p_open', []],
219309a0852SAndreas Gohr                ['cdata', ["foo\nbar"]],
220309a0852SAndreas Gohr                ['p_close', []],
221309a0852SAndreas Gohr            ]]],
222309a0852SAndreas Gohr            ['quote_close', []],
223309a0852SAndreas Gohr            ['document_end', []],
224309a0852SAndreas Gohr        ];
225309a0852SAndreas Gohr        $this->assertCalls($expected, $this->H->calls);
226309a0852SAndreas Gohr    }
227309a0852SAndreas Gohr
228309a0852SAndreas Gohr    public function testMdMultiParagraph()
229309a0852SAndreas Gohr    {
230309a0852SAndreas Gohr        // `>` alone between content lines creates two paragraphs in one
231309a0852SAndreas Gohr        // blockquote — under MD-preferred the post-pass does not run, so
232309a0852SAndreas Gohr        // the sub-parser's `p_open` / `p_close` pairs survive intact.
23313a62f81SAndreas Gohr        $this->setSyntax('md');
234309a0852SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
235309a0852SAndreas Gohr        $this->P->parse("> foo\n>\n> bar\n");
236309a0852SAndreas Gohr
237309a0852SAndreas Gohr        $names = $this->flatNames($this->H->calls);
238309a0852SAndreas Gohr        $pOpens = array_filter($names, static fn($n) => $n === 'p_open');
239309a0852SAndreas Gohr        $pCloses = array_filter($names, static fn($n) => $n === 'p_close');
240309a0852SAndreas Gohr        $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote');
241309a0852SAndreas Gohr        $this->assertCount(2, $pCloses);
242309a0852SAndreas Gohr    }
243309a0852SAndreas Gohr
244dccbd514SAndreas Gohr    // ----- Handoff from preceding block modes ----------------------------
245dccbd514SAndreas Gohr    //
246dccbd514SAndreas Gohr    // GfmTable, DW Table, and DW Listblock all consume the boundary \n on
247dccbd514SAndreas Gohr    // their way out (their exit pattern is \n by structural necessity: at
248dccbd514SAndreas Gohr    // the boundary there is no leading unmatched content for a zero-width
249dccbd514SAndreas Gohr    // lookahead exit to attach to). The pattern (?:^|\n)>... lets GfmQuote
250dccbd514SAndreas Gohr    // open at the line that starts the blockquote regardless of whether
251dccbd514SAndreas Gohr    // the preceding mode left the \n in the stream.
252dccbd514SAndreas Gohr
253dccbd514SAndreas Gohr    public function testHandoffFromGfmTable()
254dccbd514SAndreas Gohr    {
255dccbd514SAndreas Gohr        // Spec example 201: a `>` line immediately following a GFM table
256dccbd514SAndreas Gohr        // ends the table and opens a blockquote. GfmTable's exit consumes
257dccbd514SAndreas Gohr        // the boundary \n, so GfmQuote relies on the line-start (^)
258dccbd514SAndreas Gohr        // alternative to fire here.
259dccbd514SAndreas Gohr        $this->setSyntax('md');
260dccbd514SAndreas Gohr        $this->P->addMode('gfm_table', new GfmTable());
261dccbd514SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
262dccbd514SAndreas Gohr        $this->P->parse("| abc | def |\n| --- | --- |\n| bar | baz |\n> bar");
263dccbd514SAndreas Gohr
264dccbd514SAndreas Gohr        $names = array_map(static fn($c) => $c[0], $this->H->calls);
265dccbd514SAndreas Gohr        $this->assertContains('table_open', $names);
266dccbd514SAndreas Gohr        $this->assertContains('table_close', $names);
267dccbd514SAndreas Gohr        $this->assertContains('quote_open', $names);
268dccbd514SAndreas Gohr        $this->assertContains('quote_close', $names);
269dccbd514SAndreas Gohr
270dccbd514SAndreas Gohr        // Order: the quote must open after the table closes.
271dccbd514SAndreas Gohr        $tableCloseIdx = array_search('table_close', $names, true);
272dccbd514SAndreas Gohr        $quoteOpenIdx  = array_search('quote_open', $names, true);
273dccbd514SAndreas Gohr        $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx);
274dccbd514SAndreas Gohr    }
275dccbd514SAndreas Gohr
276dccbd514SAndreas Gohr    public function testHandoffFromDwTable()
277dccbd514SAndreas Gohr    {
278dccbd514SAndreas Gohr        // Same as the GFM-table case for a DW-style table. DW Table's
279dccbd514SAndreas Gohr        // exit also consumes \n, so the line-start (^) alternative is
280dccbd514SAndreas Gohr        // what lets the blockquote open.
281dccbd514SAndreas Gohr        $this->setSyntax('dw');
282dccbd514SAndreas Gohr        $this->P->addMode('table', new Table());
283dccbd514SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
284dccbd514SAndreas Gohr        $this->P->parse("| foo | bar |\n> baz");
285dccbd514SAndreas Gohr
286dccbd514SAndreas Gohr        $names = array_map(static fn($c) => $c[0], $this->H->calls);
287dccbd514SAndreas Gohr        $this->assertContains('table_open', $names);
288dccbd514SAndreas Gohr        $this->assertContains('table_close', $names);
289dccbd514SAndreas Gohr        $this->assertContains('quote_open', $names);
290dccbd514SAndreas Gohr        $this->assertContains('quote_close', $names);
291dccbd514SAndreas Gohr
292dccbd514SAndreas Gohr        $tableCloseIdx = array_search('table_close', $names, true);
293dccbd514SAndreas Gohr        $quoteOpenIdx  = array_search('quote_open', $names, true);
294dccbd514SAndreas Gohr        $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx);
295dccbd514SAndreas Gohr    }
296dccbd514SAndreas Gohr
297dccbd514SAndreas Gohr    public function testHandoffFromDwListblock()
298dccbd514SAndreas Gohr    {
299dccbd514SAndreas Gohr        // DW Listblock also exits on \n, consuming the boundary. The
300dccbd514SAndreas Gohr        // line-start alternative lets a `>` line right after the list
301dccbd514SAndreas Gohr        // open a blockquote without an intervening blank line.
302dccbd514SAndreas Gohr        $this->setSyntax('dw');
303dccbd514SAndreas Gohr        $this->P->addMode('listblock', new Listblock());
304dccbd514SAndreas Gohr        $this->P->addMode('gfm_quote', new GfmQuote());
305dccbd514SAndreas Gohr        $this->P->parse("  * foo\n  * bar\n> baz");
306dccbd514SAndreas Gohr
307dccbd514SAndreas Gohr        $names = array_map(static fn($c) => $c[0], $this->H->calls);
308dccbd514SAndreas Gohr        $this->assertContains('listu_open', $names);
309dccbd514SAndreas Gohr        $this->assertContains('listu_close', $names);
310dccbd514SAndreas Gohr        $this->assertContains('quote_open', $names);
311dccbd514SAndreas Gohr        $this->assertContains('quote_close', $names);
312dccbd514SAndreas Gohr
313dccbd514SAndreas Gohr        $listCloseIdx  = array_search('listu_close', $names, true);
314dccbd514SAndreas Gohr        $quoteOpenIdx  = array_search('quote_open', $names, true);
315dccbd514SAndreas Gohr        $this->assertGreaterThan($listCloseIdx, $quoteOpenIdx);
316dccbd514SAndreas Gohr    }
317dccbd514SAndreas Gohr
318309a0852SAndreas Gohr    public function testMdListInsideQuote()
319309a0852SAndreas Gohr    {
320309a0852SAndreas Gohr        // GfmListblock is loaded under MD-preferred syntax, so a list
321309a0852SAndreas Gohr        // inside a quote parses as a real list. The sub-parser's list
322309a0852SAndreas Gohr        // calls land inside the outer `nest` wrapper.
32313a62f81SAndreas Gohr        $this->setSyntax('md');
324309a0852SAndreas Gohr        // Add the registry's full mode set so gfm_listblock is reachable
325*47a02a10SAndreas Gohr        // via the sub-parser (the sub-parser inherits this registry, whose
326*47a02a10SAndreas Gohr        // syntax determines which modes load).
327*47a02a10SAndreas Gohr        foreach ($this->registry->getModes() as $m) {
328309a0852SAndreas Gohr            $this->P->addMode($m['mode'], $m['obj']);
329309a0852SAndreas Gohr        }
330309a0852SAndreas Gohr
331309a0852SAndreas Gohr        $this->P->parse("> - foo\n> - bar\n");
332309a0852SAndreas Gohr
333309a0852SAndreas Gohr        $names = $this->flatNames($this->H->calls);
334309a0852SAndreas Gohr        $this->assertContains('quote_open', $names);
335309a0852SAndreas Gohr        $this->assertContains('listu_open', $names, 'list inside quote must parse');
336309a0852SAndreas Gohr        $this->assertContains('listu_close', $names);
337309a0852SAndreas Gohr        $this->assertContains('quote_close', $names);
338309a0852SAndreas Gohr    }
339309a0852SAndreas Gohr}
340