Every id the tree already carries, reserved * before the walk starts so nothing minted can * take one from a node further down. */ private array $reserved = []; /** @var array Ids a node has actually been given, this walk. */ private array $claimed = []; /** @var array First claimant of each supplied identity. */ private array $references = []; /** The lowest number not yet handed out. */ private int $next = 1; private function __construct( private readonly string $key, ) { } /** * The identities in a tree, ready to hand back what it already had. * * **Pre-scanned, and that is the difference between "stable" and "usually * stable".** Minting from a bare counter as the walk goes would let a node * with no id take `n1` from a node further down the tree that already had * it — which then gets re-minted, and its translation moves. The one thing * this class exists to prevent. * * The scan is structural rather than vocabulary-driven: anything shaped * like a node, anywhere under the steps. A `split`'s far pane is exactly * where a second reader forgets to look, and a scan that walks the shape * cannot forget. * * @param mixed $steps */ public static function in($steps, string $key): self { $ids = new self($key); self::scan($steps, $ids); return $ids; } /** * The id this node keeps: its own where it has a usable one, a fresh one * otherwise. * * @param mixed $id */ public function claim($id): string { // Reserved and claimed are two different facts, and conflating them is // the bug that makes this class not idempotent: on a tree that already // has ids, EVERY id is reserved, so a node checking only that would // find its own id spoken for and mint a new one — renaming every string // on the site on the next save. if (is_string($id) && preg_match(self::SHAPE, $id) === 1 && !isset($this->claimed[$id])) { $this->claimed[$id] = true; $this->references[$id] ??= $id; return $id; } while (isset($this->reserved['n' . $this->next]) || isset($this->claimed['n' . $this->next])) { $this->next++; } $minted = 'n' . $this->next; $this->claimed[$minted] = true; if (is_string($id) && $id !== '') $this->references[$id] ??= $minted; return $minted; } /** A dangling reference must not accidentally bind to a freshly minted node. */ public function reserveReference(string $id): void { if (preg_match(self::SHAPE, $id) === 1) $this->reserved[$id] = true; } /** Keep references attached to the original node when its supplied ID is reminted. */ public function reference(string $id): string { return $this->references[$id] ?? $id; } /** * Reserve every id already in the tree, so nothing minted can collide with * one the walk has not reached yet. * * @param mixed $node */ private static function scan($node, self $ids): void { if (!is_array($node)) { return; } $id = $node[$ids->key] ?? null; if (is_string($id) && preg_match(self::SHAPE, $id) === 1) { $ids->reserved[$id] = true; } foreach ($node as $value) { self::scan($value, $ids); } } }