xref: /dokuwiki/inc/Parsing/Handler/AbstractListsRewriter.php (revision 74031e463764923581b9204cebc0fc3f34ce881f)
1<?php
2
3namespace dokuwiki\Parsing\Handler;
4
5/**
6 * Shared state machine for list-block CallWriter rewriters.
7 *
8 * Buffers flat list_open / list_item / list_close calls and reorganises them
9 * into the nested listu_open / listo_open / listitem_open / listcontent_*
10 * structure that DokuWiki renderers consume. Subclasses supply the
11 * syntax-specific marker parser via {@see interpretSyntax}; the depth/level
12 * math and the list_open argument shape are uniform across DW and GFM.
13 */
14abstract class AbstractListsRewriter extends AbstractRewriter
15{
16    /** @var array[] flat list of calls produced by this rewriter */
17    protected $listCalls = [];
18
19    /** @var array[] each entry: [listType, depth, listitemOpenIndex] */
20    protected $listStack = [];
21
22    /** @var int depth of the very first item in the block; used to normalise level numbers */
23    protected $initialDepth = 0;
24
25    /** Marker value for a listitem_open's second argument when the item has child lists */
26    public const NODE = 1;
27
28    //region CallWriter integration
29
30    /** @inheritdoc */
31    protected function getClosingCall(): string
32    {
33        return 'list_close';
34    }
35
36    /** @inheritdoc */
37    public function process()
38    {
39        foreach ($this->calls as $call) {
40            match ($call[0]) {
41                'list_open'  => $this->handleListOpen($call),
42                'list_item'  => $this->handleListItem($call),
43                'list_close' => $this->handleListClose($call),
44                default      => $this->listContent($call),
45            };
46        }
47
48        $this->callWriter->writeCalls($this->listCalls);
49        return $this->callWriter;
50    }
51
52    //endregion
53
54    //region Event handlers
55
56    /**
57     * Open the list and the first item in response to a list_open event.
58     *
59     * @param array $call buffered call: [name, args, pos]
60     */
61    protected function handleListOpen($call)
62    {
63        ['depth' => $depth, 'type' => $type, 'start' => $start] = $this->parseMarker($call[1][0]);
64
65        $this->initialDepth = $depth;
66        $this->emitOpenList($type, $start, $depth, $call[2]);
67    }
68
69    /**
70     * Handle a list_item event: close the previous item, open the next one,
71     * adjusting the listStack for type changes and depth transitions.
72     *
73     * @param array $call buffered call: [name, args, pos]
74     */
75    protected function handleListItem($call)
76    {
77        ['depth' => $depth, 'type' => $type, 'start' => $start] = $this->parseMarker($call[1][0]);
78        $top = end($this->listStack);
79        $pos = $call[2];
80
81        if ($depth < $this->initialDepth) {
82            $depth = $this->initialDepth;
83        }
84
85        if ($depth == $top[1]) {
86            // Same depth: either a sibling item or a type switch.
87            $this->emitCloseItem($pos);
88            if ($type === $top[0]) {
89                $this->emitOpenItem($depth, $pos);
90            } else {
91                $this->emitCloseList($pos);
92                $this->emitOpenList($type, $start, $depth, $pos);
93            }
94        } elseif ($depth > $top[1]) {
95            // Deeper: open a nested list, mark the parent item as a node.
96            $this->listCalls[] = ['listcontent_close', [], $pos];
97            $this->markCurrentItemAsNode();
98            $this->emitOpenList($type, $start, $depth, $pos);
99        } else {
100            // Shallower: close the current item and list, unwind to the
101            // first list whose depth is <= target, then open at that depth.
102            $this->emitCloseItem($pos);
103            $this->emitCloseList($pos);
104
105            while (($top = end($this->listStack)) && $top[1] > $depth) {
106                $this->listCalls[] = ['listitem_close', [], $pos];
107                $this->emitCloseList($pos);
108            }
109
110            $depth = $top[1];
111            $this->listCalls[] = ['listitem_close', [], $pos];
112            if ($top[0] === $type) {
113                $this->emitOpenItem($depth, $pos);
114            } else {
115                $this->emitCloseList($pos);
116                $this->emitOpenList($type, $start, $depth, $pos);
117            }
118        }
119    }
120
121    /**
122     * Pass through any non-list call (the inline / block content emitted
123     * inside an item) into the buffered call stream untouched.
124     *
125     * @param array $call buffered call: [name, args, pos]
126     */
127    protected function listContent($call)
128    {
129        $this->listCalls[] = $call;
130    }
131
132    /**
133     * Close all open items and lists in response to a list_close event.
134     *
135     * @param array $call buffered call: [name, args, pos]
136     */
137    protected function handleListClose($call)
138    {
139        $first = true;
140        while (!empty($this->listStack)) {
141            if ($first) {
142                $this->listCalls[] = ['listcontent_close', [], $call[2]];
143                $first = false;
144            }
145            $this->listCalls[] = ['listitem_close', [], $call[2]];
146            $this->emitCloseList($call[2]);
147        }
148    }
149
150    //endregion
151
152    //region Emit helpers
153
154    /**
155     * Open a new list and its first item; push a new stack frame.
156     *
157     * @param string $type list type — 'u' (unordered) or 'o' (ordered)
158     * @param int $start ordered-list start number; 1 means default (no `start` attribute)
159     * @param int $depth absolute nesting depth of the new list
160     * @param int $pos byte position to attach to the emitted calls
161     */
162    protected function emitOpenList(string $type, int $start, int $depth, int $pos): void
163    {
164        $this->listCalls[] = ['list' . $type . '_open', $this->listOpenArgs($type, $start), $pos];
165        $this->listCalls[] = ['listitem_open', $this->levelArgs($depth), $pos];
166        $this->listCalls[] = ['listcontent_open', [], $pos];
167        $this->listStack[] = [$type, $depth, count($this->listCalls) - 2];
168    }
169
170    /**
171     * Open a new sibling item in the current list; update the current
172     * stack frame's listitem_open index so a later child can mark it as
173     * a node.
174     *
175     * @param int $depth absolute nesting depth of the new item
176     * @param int $pos byte position to attach to the emitted calls
177     */
178    protected function emitOpenItem(int $depth, int $pos): void
179    {
180        $this->listCalls[] = ['listitem_open', $this->levelArgs($depth), $pos];
181        $this->listCalls[] = ['listcontent_open', [], $pos];
182        $key = array_key_last($this->listStack);
183        $this->listStack[$key][2] = count($this->listCalls) - 2;
184    }
185
186    /**
187     * Mark the current top-of-stack item as a node (i.e. it has a child
188     * list). Sets the second arg of its listitem_open call to NODE.
189     *
190     * Whether an item is a node or a leaf is information from the future:
191     * we only learn it when the next list_item arrives at a deeper depth.
192     * So listitem_open is emitted eagerly as a leaf, and this method
193     * patches the already-buffered call when a child shows up. Each stack
194     * frame caches the buffer index of its listitem_open ($listStack[$key][2])
195     * so the patch is a single random-access write.
196     */
197    protected function markCurrentItemAsNode(): void
198    {
199        $key = array_key_last($this->listStack);
200        $this->listCalls[$this->listStack[$key][2]][1][1] = self::NODE;
201    }
202
203    /**
204     * Emit a complete item close: listcontent_close + listitem_close.
205     *
206     * @param int $pos byte position to attach to the emitted calls
207     */
208    protected function emitCloseItem(int $pos): void
209    {
210        $this->listCalls[] = ['listcontent_close', [], $pos];
211        $this->listCalls[] = ['listitem_close', [], $pos];
212    }
213
214    /**
215     * Emit a list close (list_X_close) for the current top of the stack
216     * and pop the stack frame.
217     *
218     * @param int $pos byte position to attach to the emitted call
219     */
220    protected function emitCloseList(int $pos): void
221    {
222        $top = end($this->listStack);
223        $this->listCalls[] = ['list' . $top[0] . '_close', [], $pos];
224        array_pop($this->listStack);
225    }
226
227    //endregion
228
229    //region Subclass hooks
230
231    /**
232     * Translate an absolute depth into a 1-based level number, normalised
233     * for lists that start at non-1 depth (DokuWiki's 2-space-indent rule
234     * gives initialDepth=2, GFM gives initialDepth=1).
235     *
236     * @param int $depth absolute nesting depth
237     * @return array single-element argument array for listitem_open
238     */
239    protected function levelArgs(int $depth): array
240    {
241        return [max(1, $depth - $this->initialDepth + 1)];
242    }
243
244    /**
245     * Build the argument array for a list_*_open call. Ordered lists with a
246     * non-default start number get the start as the second argument; everything
247     * else uses an empty array so renderer defaults take effect (1 / no
248     * `start` attribute on the rendered <ol>).
249     *
250     * @param string $type 'u' or 'o'
251     * @param int $start start number (1 means default; no attribute emitted)
252     */
253    protected function listOpenArgs(string $type, int $start): array
254    {
255        if ($type === 'o' && $start !== 1) {
256            return [null, $start];
257        }
258        return [];
259    }
260
261    /**
262     * Parse a marker match into the values driving the state machine.
263     *
264     * Subclasses may omit `start`; it defaults to 1. Syntaxes whose ordered
265     * lists do not carry a start number (DokuWiki) take the default; GFM
266     * supplies the explicit value parsed from the marker.
267     *
268     * @param string $match the indent + marker string captured by the parser
269     * @return array{depth: int, type: string, start?: int}
270     *   depth is 1-based, type is 'u' (unordered) or 'o' (ordered),
271     *   start is the ordered list's start number; default 1 (no attribute
272     *   emitted) for unordered lists or ordered lists that begin at 1.
273     */
274    abstract protected function interpretSyntax(string $match): array;
275
276    /**
277     * Wrap interpretSyntax to apply the default for omitted keys.
278     */
279    private function parseMarker(string $match): array
280    {
281        return $this->interpretSyntax($match) + ['start' => 1];
282    }
283
284    //endregion
285}
286