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