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