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