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