$baseline * @param array $current * @param array $changedKeys * @param array $labels * @param array> $meta */ public function present( array $baseline, array $current, array $changedKeys = [], array $labels = [], array $meta = [], ): DiffPresentation { $keys = []; foreach ([array_keys($baseline), array_keys($current), $changedKeys] as $sourceKeys) { foreach ($sourceKeys as $key) { if (! is_string($key) || trim($key) === '') { continue; } $keys[$key] = true; } } if ($keys === []) { return DiffPresentation::empty(); } $changedLookup = []; foreach ($changedKeys as $changedKey) { if (! is_string($changedKey) || trim($changedKey) === '') { continue; } $changedLookup[$changedKey] = true; } $rows = []; foreach (array_keys($keys) as $key) { $hasOldValue = array_key_exists($key, $baseline); $hasNewValue = array_key_exists($key, $current); if (! $hasOldValue && ! $hasNewValue) { continue; } $oldValue = $baseline[$key] ?? null; $newValue = $current[$key] ?? null; $isListLike = $this->isListRowCandidate($hasOldValue, $oldValue, $hasNewValue, $newValue); [$addedItems, $removedItems, $unchangedItems] = $isListLike ? $this->prepareListFragments( $hasOldValue && is_array($oldValue) ? $oldValue : [], $hasNewValue && is_array($newValue) ? $newValue : [], ) : [[], [], []]; $rows[] = new DiffRow( key: $key, label: $this->labelForKey($key, $labels[$key] ?? null), status: $this->statusFor( key: $key, hasOldValue: $hasOldValue, hasNewValue: $hasNewValue, oldValue: $oldValue, newValue: $newValue, changedLookup: $changedLookup, ), oldValue: $oldValue, newValue: $newValue, isListLike: $isListLike, addedItems: $addedItems, removedItems: $removedItems, unchangedItems: $unchangedItems, meta: is_array($meta[$key] ?? null) ? $meta[$key] : [], ); } usort($rows, function (DiffRow $left, DiffRow $right): int { return [mb_strtolower($left->label), $left->key] <=> [mb_strtolower($right->label), $right->key]; }); return new DiffPresentation( summary: DiffSummary::fromRows($rows), rows: $rows, ); } /** * @param array $changedLookup */ private function statusFor( string $key, bool $hasOldValue, bool $hasNewValue, mixed $oldValue, mixed $newValue, array $changedLookup, ): DiffRowStatus { if (! $hasOldValue) { return DiffRowStatus::Added; } if (! $hasNewValue) { return DiffRowStatus::Removed; } if (array_key_exists($key, $changedLookup) || $oldValue !== $newValue) { return DiffRowStatus::Changed; } return DiffRowStatus::Unchanged; } private function labelForKey(string $key, mixed $label): string { if (is_string($label) && trim($label) !== '') { return trim($label); } $segments = preg_split('/\s*>\s*|\./', $key) ?: []; $segments = array_values(array_filter(array_map(function (string $segment): string { return Str::of($segment) ->replace(['_', '-'], ' ') ->headline() ->trim() ->toString(); }, $segments))); if ($segments !== []) { return implode(' > ', $segments); } return Str::of($key)->headline()->toString(); } private function isListRowCandidate( bool $hasOldValue, mixed $oldValue, bool $hasNewValue, mixed $newValue, ): bool { if ($hasOldValue && $hasNewValue) { return $this->isInlineList($oldValue) && $this->isInlineList($newValue); } if ($hasOldValue) { return $this->isInlineList($oldValue); } return $this->isInlineList($newValue); } private function isInlineList(mixed $value): bool { if (! is_array($value) || ! array_is_list($value)) { return false; } foreach ($value as $item) { if (! $this->isInlineListItem($item)) { return false; } } return true; } private function isInlineListItem(mixed $item): bool { return $item === null || is_scalar($item) || $item instanceof BackedEnum || $item instanceof Stringable; } /** * @param array $baseline * @param array $current * @return array{0: array, 1: array, 2: array} */ private function prepareListFragments(array $baseline, array $current): array { $remainingBaseline = []; foreach ($baseline as $item) { $signature = $this->listItemSignature($item); $remainingBaseline[$signature] = ($remainingBaseline[$signature] ?? 0) + 1; } $addedItems = []; $unchangedItems = []; foreach ($current as $item) { $signature = $this->listItemSignature($item); if (($remainingBaseline[$signature] ?? 0) > 0) { $unchangedItems[] = $item; $remainingBaseline[$signature]--; continue; } $addedItems[] = $item; } $removedItems = []; foreach ($baseline as $item) { $signature = $this->listItemSignature($item); if (($remainingBaseline[$signature] ?? 0) <= 0) { continue; } $removedItems[] = $item; $remainingBaseline[$signature]--; } return [$addedItems, $removedItems, $unchangedItems]; } private function listItemSignature(mixed $item): string { return serialize($this->normalizeListItem($item)); } private function normalizeListItem(mixed $item): mixed { if ($item instanceof BackedEnum) { return $item->value; } if ($item instanceof Stringable) { return (string) $item; } return $item; } }