xref: /dokuwiki/inc/Parsing/Handler/AbstractListsRewriter.php (revision 95f694202286c1add4c442936a5caa38db0dd603)
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     * Ordered lists with a non-default start number are emitted as the
158     * sibling instruction listo_open_start to keep the listo_open signature
159     * unchanged for plugin renderers that override it.
160     *
161     * @param string $type list type - 'u' (unordered) or 'o' (ordered)
162     * @param int $start ordered-list start number; 1 means default
163     * @param int $depth absolute nesting depth of the new list
164     * @param int $pos byte position to attach to the emitted calls
165     */
166    protected function emitOpenList(string $type, int $start, int $depth, int $pos): void
167    {
168        if ($type === 'o' && $start !== 1) {
169            $this->listCalls[] = ['listo_open_start', [$start], $pos];
170        } else {
171            $this->listCalls[] = ['list' . $type . '_open', [], $pos];
172        }
173        $this->listCalls[] = ['listitem_open', $this->levelArgs($depth), $pos];
174        $this->listCalls[] = ['listcontent_open', [], $pos];
175        $this->listStack[] = [$type, $depth, count($this->listCalls) - 2];
176    }
177
178    /**
179     * Open a new sibling item in the current list; update the current
180     * stack frame's listitem_open index so a later child can mark it as
181     * a node.
182     *
183     * @param int $depth absolute nesting depth of the new item
184     * @param int $pos byte position to attach to the emitted calls
185     */
186    protected function emitOpenItem(int $depth, int $pos): void
187    {
188        $this->listCalls[] = ['listitem_open', $this->levelArgs($depth), $pos];
189        $this->listCalls[] = ['listcontent_open', [], $pos];
190        $key = array_key_last($this->listStack);
191        $this->listStack[$key][2] = count($this->listCalls) - 2;
192    }
193
194    /**
195     * Mark the current top-of-stack item as a node (i.e. it has a child
196     * list). Sets the second arg of its listitem_open call to NODE.
197     *
198     * Whether an item is a node or a leaf is information from the future:
199     * we only learn it when the next list_item arrives at a deeper depth.
200     * So listitem_open is emitted eagerly as a leaf, and this method
201     * patches the already-buffered call when a child shows up. Each stack
202     * frame caches the buffer index of its listitem_open ($listStack[$key][2])
203     * so the patch is a single random-access write.
204     */
205    protected function markCurrentItemAsNode(): void
206    {
207        $key = array_key_last($this->listStack);
208        $this->listCalls[$this->listStack[$key][2]][1][1] = self::NODE;
209    }
210
211    /**
212     * Emit a complete item close: listcontent_close + listitem_close.
213     *
214     * @param int $pos byte position to attach to the emitted calls
215     */
216    protected function emitCloseItem(int $pos): void
217    {
218        $this->listCalls[] = ['listcontent_close', [], $pos];
219        $this->listCalls[] = ['listitem_close', [], $pos];
220    }
221
222    /**
223     * Emit a list close (list_X_close) for the current top of the stack
224     * and pop the stack frame.
225     *
226     * @param int $pos byte position to attach to the emitted call
227     */
228    protected function emitCloseList(int $pos): void
229    {
230        $top = end($this->listStack);
231        $this->listCalls[] = ['list' . $top[0] . '_close', [], $pos];
232        array_pop($this->listStack);
233    }
234
235    //endregion
236
237    //region Subclass hooks
238
239    /**
240     * Translate an absolute depth into a 1-based level number, normalised
241     * for lists that start at non-1 depth (DokuWiki's 2-space-indent rule
242     * gives initialDepth=2, GFM gives initialDepth=1).
243     *
244     * @param int $depth absolute nesting depth
245     * @return array single-element argument array for listitem_open
246     */
247    protected function levelArgs(int $depth): array
248    {
249        return [max(1, $depth - $this->initialDepth + 1)];
250    }
251
252    /**
253     * Parse a marker match into the values driving the state machine.
254     *
255     * Subclasses may omit `start`; it defaults to 1. Syntaxes whose ordered
256     * lists do not carry a start number (DokuWiki) take the default; GFM
257     * supplies the explicit value parsed from the marker.
258     *
259     * @param string $match the indent + marker string captured by the parser
260     * @return array{depth: int, type: string, start?: int}
261     *   depth is 1-based, type is 'u' (unordered) or 'o' (ordered),
262     *   start is the ordered list's start number; default 1 (no attribute
263     *   emitted) for unordered lists or ordered lists that begin at 1.
264     */
265    abstract protected function interpretSyntax(string $match): array;
266
267    /**
268     * Wrap interpretSyntax to apply the default for omitted keys.
269     */
270    private function parseMarker(string $match): array
271    {
272        return $this->interpretSyntax($match) + ['start' => 1];
273    }
274
275    //endregion
276}
277