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