xref: /dokuwiki/inc/Parsing/ParserMode/GfmListblock.php (revision 3361bf0a349cd394d011fd1ebb19a11d50c13a36)
1<?php
2
3namespace dokuwiki\Parsing\ParserMode;
4
5use dokuwiki\Parsing\Handler;
6use dokuwiki\Parsing\Handler\GfmLists;
7use dokuwiki\Parsing\Handler\Nest;
8use dokuwiki\Parsing\ModeRegistry;
9
10/**
11 * GFM list block.
12 *
13 * Captures an entire list block atomically (one addSpecialPattern match) and
14 * walks the captured text in handle(), grouping lines into items. A pooled
15 * sub-parser is acquired from the ModeRegistry for the duration of the per-item
16 * loop so each item's body is dedented to its content column and parsed by it,
17 * and block content - paragraphs, fenced code, blockquotes, plugin blocks -
18 * works inside items uniformly without depending on column-0 anchoring of
19 * nested mode patterns. If any nested mode requests a sub-parser with the
20 * same exclusion key while ours is in use, the registry's pool hands them a
21 * different slot so their reset() does not corrupt our state.
22 *
23 * Sub-parser mode set: every active mode except CATEGORY_BASEONLY (i.e. no
24 * Header inside list items, since `<h1>`-`<h6>` inside `<li>` is never
25 * desirable and section nesting must not span into items) and gfm_listblock
26 * itself (defensive guard against lexer re-entry on pathological inputs;
27 * normal nested lists are caught by the outer pattern instead).
28 *
29 * Each item's sub-parsed calls are wrapped in a nest instruction (see
30 * Handler\Nest) before they reach the outer handler. The sub-parse has
31 * already run its own finalize - the call-writer rewriters plus the
32 * Block paragraph pass - producing a complete instruction sequence for
33 * the item. The main handler finalizes the whole document afterwards;
34 * wrapping the item in nest keeps it opaque to that second pass (Block
35 * copies a nest call verbatim instead of descending into it), so the
36 * item's block structure is preserved and replayed as-is by the renderer
37 * base class. This is the isolation Footnote has always relied on nest
38 * for, so nested block content is not re-stitched by the outer rewriters.
39 * Nest also merges adjacent cdata and drops stray eol as it buffers,
40 * sealing the item boundary.
41 *
42 * Indentation rule: depth = (indent / 2) + 1. Tabs become two spaces. 1- and
43 * 3-space indents round down. Marker characters: -, *, + (unordered) and
44 * digits followed by . or ) (ordered). Nested lists are caught by the
45 * outer pattern (each marker at any 2-space-multiple indent is its own
46 * item at the corresponding depth) and stitched back into nested HTML by
47 * the GfmLists rewriter.
48 */
49class GfmListblock extends AbstractMode
50{
51    /**
52     * Regex fragment matching one list marker.
53     *
54     * Either an unordered marker (`-`, `*`, `+`) or an ordered marker
55     * (1-9 digits followed by `.` or `)`). Used by the entry pattern in
56     * connectTo() and by the per-line classifier in parseItems().
57     */
58    protected const MARKER = '(?:[-*+]|\d{1,9}[.)])';
59
60    /** @inheritdoc */
61    public function getSort()
62    {
63        return 10;
64    }
65
66    /** @inheritdoc */
67    public function preConnect()
68    {
69        $this->registry->registerBlockEolMode('gfm_listblock');
70    }
71
72    /**
73     * Register the special pattern that captures a whole list block.
74     *
75     * The pattern starts on a marker line (any indent) and then loops over
76     * four alternatives until none matches:
77     *
78     *   1. A subsequent marker line at any indent.
79     *   2. An indented continuation line (>= 2 leading spaces with content).
80     *   3. A blank line followed by indented content (any number of
81     *      intervening blank lines tolerated via the lookahead).
82     *   4. A blank line followed by a next marker (same multi-blank
83     *      tolerance as alt 3).
84     *
85     * The block ends naturally when none of the alternatives match — for
86     * example a column-0 non-marker line, or two-or-more blank lines
87     * followed by non-list content.
88     *
89     * @inheritdoc
90     */
91    public function connectTo($mode)
92    {
93        $pattern =
94            '\n[ \t]*' . self::MARKER . '(?:[ \t][^\n]*|(?=\n))' .
95            '(?:' .
96                '\n[ \t]*' . self::MARKER . '(?:[ \t][^\n]*|(?=\n))' .
97            '|' . '\n[ \t]{2,}\S[^\n]*' .
98            '|' . '\n[ \t]*(?=(?:\n[ \t]*)*\n[ \t]{2,}\S)' .
99            '|' . '\n[ \t]*(?=(?:\n[ \t]*)*\n[ \t]*' . self::MARKER . ')' .
100            ')*';
101        $this->Lexer->addSpecialPattern($pattern, $mode, 'gfm_listblock');
102    }
103
104    /**
105     * Convert the captured block into handler calls.
106     *
107     * Sequence:
108     *   1. parseItems() splits the captured text into per-item records.
109     *   2. Install GfmLists as a CallWriter rewriter on the main handler.
110     *   3. Emit list_open carrying the first item's marker — the rewriter's
111     *      handleListOpen opens the `<ul>`/`<ol>` and the first `<li>`.
112     *   4. For each item:
113     *        - If not the first, emit list_item (closes the previous `<li>`
114     *          and opens a new one in the rewriter).
115     *        - Sub-parse the dedented item body via the cached sub-parser.
116     *        - Filter document_start/end and the outer p_open/p_close pair
117     *          for tight items (single paragraph).
118     *        - Wrap the filtered calls in a Nest so the main handler's
119     *          Block rewriter treats them as opaque.
120     *   5. Emit list_close and finalise the GfmLists rewriter.
121     *
122     * @inheritdoc
123     */
124    public function handle($match, $state, $pos, Handler $handler)
125    {
126        $items = $this->parseItems($match);
127        if (empty($items)) {
128            $handler->addCall('cdata', [$match], $pos);
129            return true;
130        }
131
132        $handler->setCallWriter(new GfmLists($handler->getCallWriter()));
133        $handler->addCall('list_open', [$items[0]['markerMatch']], $pos);
134
135        $registry = $this->registry;
136        $excludeCats = [ModeRegistry::CATEGORY_BASEONLY];
137        $excludeModes = ['gfm_listblock'];
138        $subParser = $registry->acquireSubParser($excludeCats, $excludeModes);
139        $subHandler = $subParser->getHandler();
140
141        foreach ($items as $i => $item) {
142            $itemPos = $pos + $item['offset'];
143            if ($i > 0) {
144                $handler->addCall('list_item', [$item['markerMatch']], $itemPos);
145            }
146
147            $subHandler->reset();
148            $subParser->parse($item['body']);
149            $itemCalls = $this->filterSubCalls($subHandler->calls);
150            if (empty($itemCalls)) continue; // empty item — nothing to emit
151
152            // Wrap the item content in a Nest so the outer handler's
153            // finalize (its rewriters and Block pass) does not re-process
154            // this already-finalized sub-tree: Block copies a nest call
155            // verbatim and the renderer base class replays it as-is.
156            $outer = $handler->getCallWriter();
157            $nest = new Nest($outer);
158            $handler->setCallWriter($nest);
159            foreach ($itemCalls as $call) {
160                // sub-handler positions are relative to the item body; offset
161                // them back into the source so section-edit anchors work.
162                $handler->addCall($call[0], $call[1], $itemPos + $call[2]);
163            }
164            $handler->setCallWriter($nest->process());
165        }
166
167        $registry->releaseSubParser($excludeCats, $excludeModes);
168
169        $handler->addCall('list_close', [], $pos + strlen($match));
170        $reWriter = $handler->getCallWriter();
171        $handler->setCallWriter($reWriter->process());
172
173        return true;
174    }
175
176    /**
177     * Walk the captured block, grouping lines into items.
178     *
179     * Each returned item describes one list_item: its marker (in the form
180     * "\n{indent}{marker}" so GfmLists::interpretSyntax can parse it), the
181     * dedented body, dedent column, and absolute offset within $match.
182     *
183     * Lines are classified as marker / continuation / blank. A marker line
184     * starts a new item; continuation and blank lines accumulate into the
185     * current item's body. Continuation lines are dedented by up to
186     * indent + marker_width + 1 leading spaces (the item's content column
187     * for single-space-after-marker cases). Blank lines are kept as empty
188     * body lines while they're in the middle of the body and stripped
189     * from the trailing edge by joinBody() so single-paragraph items
190     * parse tight.
191     *
192     * @param string $match the raw special-pattern match (starts with \n)
193     * @return array<int, array{markerMatch: string, dedent: int, body: string, offset: int}>
194     */
195    protected function parseItems($match)
196    {
197        $stripped = ltrim($match, "\n");
198        $offsetBase = strlen($match) - strlen($stripped);
199        $lines = explode("\n", $stripped);
200
201        $items = [];
202        $current = null;
203        $bodyLines = [];
204        $cursor = $offsetBase;
205
206        foreach ($lines as $line) {
207            $isMarker = preg_match(
208                '/^([ \t]*)(' . self::MARKER . ')(?:[ \t](.*)|$)/',
209                $line,
210                $m
211            );
212
213            if ($isMarker) {
214                if ($current !== null) {
215                    $current['body'] = $this->joinBody($bodyLines);
216                    $items[] = $current;
217                }
218                $indent = str_replace("\t", "  ", $m[1]);
219                $marker = $m[2];
220                $firstLine = $m[3] ?? '';
221                $current = [
222                    'markerMatch' => "\n" . $indent . $marker,
223                    'dedent' => strlen($indent) + strlen($marker) + 1,
224                    'offset' => $cursor,
225                ];
226                $bodyLines = [$firstLine];
227            } elseif ($current !== null) {
228                if (trim($line) === '') {
229                    $bodyLines[] = '';
230                } else {
231                    $expanded = str_replace("\t", "  ", $line);
232                    $available = strlen($expanded) - strlen(ltrim($expanded, ' '));
233                    $strip = min($current['dedent'], $available);
234                    $bodyLines[] = substr($expanded, $strip);
235                }
236            }
237
238            $cursor += strlen($line) + 1; // +1 for the \n consumed by explode
239        }
240
241        if ($current !== null) {
242            $current['body'] = $this->joinBody($bodyLines);
243            $items[] = $current;
244        }
245
246        return $items;
247    }
248
249    /**
250     * Join body lines into a string, trimming trailing blank lines.
251     *
252     * Trailing blanks would reach the sub-parser and cause Block to wrap
253     * the otherwise-single paragraph content in `p_open`/`p_close`,
254     * forcing a tight item into loose-item shape. Stripping them here
255     * preserves the tight rendering for items that look tight in source.
256     *
257     * @param string[] $lines
258     */
259    protected function joinBody(array $lines): string
260    {
261        return rtrim(implode("\n", $lines), "\n");
262    }
263
264    /**
265     * Filter the sub-parser's flat call list before nest-wrapping it.
266     *
267     * Drops `document_start` / `document_end` (always emitted by
268     * Handler::finalize), and strips the outer `p_open` / `p_close` pair
269     * for tight items so their content sits inline inside `<li>`. Loose
270     * items (multiple paragraphs, more than one `p_open`) keep their
271     * inner pairs untouched. The filtered calls are then wrapped in a
272     * Nest by handle() before they reach the GfmLists rewriter.
273     *
274     * @param array $calls
275     * @return array
276     */
277    protected function filterSubCalls(array $calls)
278    {
279        if ($calls && $calls[0][0] === 'document_start') array_shift($calls);
280        if ($calls && end($calls)[0] === 'document_end') array_pop($calls);
281
282        $pCount = 0;
283        foreach ($calls as $c) {
284            if ($c[0] === 'p_open') $pCount++;
285        }
286
287        if (
288            $pCount === 1
289            && $calls[0][0] === 'p_open'
290            && end($calls)[0] === 'p_close'
291        ) {
292            array_shift($calls);
293            array_pop($calls);
294        }
295
296        return $calls;
297    }
298}
299