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