diff --git a/lib/Horde/ActiveSync/IntRangeSet.php b/lib/Horde/ActiveSync/IntRangeSet.php new file mode 100644 index 00000000..7045a36e --- /dev/null +++ b/lib/Horde/ActiveSync/IntRangeSet.php @@ -0,0 +1,478 @@ + + */ +class IntRangeSet implements \IteratorAggregate +{ + /** + * Flat array of alternating start/end values: [start1, end1, start2, end2, ...] + * Even indices are range starts, odd indices are range ends. + * @var int[] + */ + private array $ranges = []; + + /** @var int Cached total count of integers in the set */ + private int $count = 0; + + /** @var int Cached last result of lowerBound to speed up repeated/sequential lookups */ + private int $lastLowerBound = 0; + + /** + * @param string $s Optional IMAP-style UID set string to initialize from. + * @throws \InvalidArgumentException on malformed input + */ + public function __construct(string $s = '') + { + if ($s === '') return; + foreach (explode(',', $s) as $part) { + if (!preg_match('/^(\d+)(?::(\d+))?$/', $part, $m)) { + throw new \InvalidArgumentException("Invalid range part: '$part'"); + } + $start = (int)$m[1]; + $end = isset($m[2]) ? (int)$m[2] : $start; + $this->addRange($start, $end); + } + } + + /** + * Set ranges and count directly. For internal use only. + */ + private function init(array $ranges, int $count): self + { + $this->ranges = $ranges; + $this->count = $count; + return $this; + } + + /** + * Create a new instance with pre-validated ranges and count. + * For internal use only. + */ + private static function create(array $ranges, int $count): self + { + return (new self())->init($ranges, $count); + } + + /** + * Return the flat index of the first range whose end >= $n, + * or count($this->ranges) if no such range exists. + * Search starts at flat index $lo (trusted lower bound). + * + * Uses a cached last result to short-circuit repeated or sequential lookups. + * The cache check uses $lastLowerBound === 0 to safely avoid out-of-bounds + * access on the preceding range's end value. + */ + private function lowerBound(int $n, int $lo = 0): int + { + $hi = count($this->ranges) - 2; + + if ($hi < $lo || $this->ranges[$hi + 1] < $n) { + return $hi + 2; + } + + if ($this->lastLowerBound >= $lo && + $this->lastLowerBound <= $hi && + $this->ranges[$this->lastLowerBound + 1] >= $n && + ($this->lastLowerBound === 0 || $this->ranges[$this->lastLowerBound - 1] < $n)) { + return $this->lastLowerBound; + } + + if ($this->ranges[$lo + 1] < $n) { + do { + $mid = (($lo + $hi) >> 1) & -2; + $midEnd = $this->ranges[$mid + 1]; + if ($midEnd < $n) $lo = $mid + 2; + elseif ($midEnd === $n) { $lo = $mid; break; } + else $hi = $mid - 2; + } while ($lo <= $hi); + } + + return $this->lastLowerBound = $lo; + } + + /** + * Add a single integer to the set, merging adjacent or overlapping ranges. + */ + public function add(int $n): self + { + return $this->addRange($n, $n); + } + + /** + * Add a range of integers [start, end] to the set, + * merging adjacent or overlapping ranges. + * If start > end the values are swapped. + * + * Fast-appends when the new range is strictly after all existing ranges, + * avoiding binary search entirely for sorted input. + */ + public function addRange(int $start, int $end): self + { + if ($start > $end) [$start, $end] = [$end, $start]; + + $c = count($this->ranges); + + // Fast append if new range starts after the last end value (odd index $c-1) + if ($c === 0 || $this->ranges[$c - 1] < $start - 1) { + $this->ranges[] = $start; + $this->ranges[] = $end; + $this->count += $end - $start + 1; + return $this; + } + + $first = $this->lowerBound($start); + $last = $start === $end ? $first : $this->lowerBound($end, $first); + + // Absorb right neighbor if adjacent or overlapping + if ($last < $c) { + $lastStart = $this->ranges[$last]; + if ($lastStart <= $end + 1) { + $end = $this->ranges[$last + 1]; + $last += 2; + } + } + + // Check left neighbor for adjacency (index $first-1 is the end value of the previous range) + if ($first > 0 && $this->ranges[$first - 1] + 1 >= $start) { + $first -= 2; + } + + // Absorb left neighbor's start if it extends further left + if ($first < $c && $this->ranges[$first] < $start) { + $start = $this->ranges[$first]; + } + + // Compute count delta: subtract absorbed ranges, add new range + $delta = $end - $start + 1; + for ($k = $first; $k < $last; $k += 2) $delta -= $this->ranges[$k + 1] - $this->ranges[$k] + 1; + $this->count += $delta; + + if ($last - $first === 2) { + $this->ranges[$first] = $start; + $this->ranges[$first + 1] = $end; + } else { + array_splice($this->ranges, $first, $last - $first, [$start, $end]); + } + return $this; + } + + /** + * Remove a range of integers [start, end] from the set, + * splitting or trimming overlapping ranges as necessary. + * If start > end the values are swapped. + */ + public function removeRange(int $start, int $end): self + { + if ($start > $end) [$start, $end] = [$end, $start]; + + $c = count($this->ranges); + $first = $this->lowerBound($start); + + if ($first >= $c || $this->ranges[$first] > $end) { + return $this; + } + + $last = $start === $end ? $first : $this->lowerBound($end, $first); + + $replacement = []; + + // If the first affected range starts before $start, preserve its left portion + $firstStart = $this->ranges[$first]; + if ($firstStart < $start) { + $replacement[] = $firstStart; + $replacement[] = $start - 1; + } + + // If the last affected range ends after $end, preserve its right portion + if ($last < $c) { + $lastEnd = $this->ranges[$last + 1]; + if ($lastEnd > $end) { + $replacement[] = $end + 1; + $replacement[] = $lastEnd; + } + $last += 2; + } + + // Compute delta: sum of absorbed ranges minus preserved portions + $delta = 0; + for ($k = $first; $k < $last; $k += 2) $delta += $this->ranges[$k + 1] - $this->ranges[$k] + 1; + $rc = count($replacement); + for ($k = 0; $k < $rc; $k += 2) $delta -= $replacement[$k + 1] - $replacement[$k] + 1; + $this->count -= $delta; + + if ($last - $first === 2 && $rc === 2) { + // Single range trimmed — update in place + $this->ranges[$first] = $replacement[0]; + $this->ranges[$first + 1] = $replacement[1]; + } else { + array_splice($this->ranges, $first, $last - $first, $replacement); + } + return $this; + } + + /** + * Remove a single integer from the set, splitting a range if necessary. + */ + public function remove(int $n): self + { + return $this->removeRange($n, $n); + } + + /** + * Merge all ranges of another IntRangeSet into this set. + * Uses a two-pointer sweep for O(m+n) performance. + */ + public function union(IntRangeSet $other): self + { + $ranges = []; + $count = 0; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -2; + if ($aHasMore) { $aIdx = 0; $aStart = $this->ranges[0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bIdx = 0; $bStart = $other->ranges[0]; $bEnd = $other->ranges[1]; } + + while ($aHasMore || $bHasMore) { + if (!$bHasMore || ($aHasMore && $aStart <= $bStart)) { + $curStart = $aStart; $curEnd = $aEnd; + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + } else { + $curStart = $bStart; $curEnd = $bEnd; + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } + + do { + $merged = false; + if ($aHasMore && $aStart <= $curEnd + 1) { + if ($aEnd > $curEnd) $curEnd = $aEnd; + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + $merged = true; + } + if ($bHasMore && $bStart <= $curEnd + 1) { + if ($bEnd > $curEnd) $curEnd = $bEnd; + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + $merged = true; + } + } while ($merged); + + $ranges[] = $curStart; + $ranges[] = $curEnd; + $count += $curEnd - $curStart + 1; + } + + return $this->init($ranges, $count); + } + + /** + * Remove all ranges of another IntRangeSet from this set. + * Uses a two-pointer sweep for O(m+n) performance. + */ + public function subtract(IntRangeSet $other): self + { + $ranges = []; + $count = 0; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -2; + if ($aHasMore) { $aIdx = 0; $aStart = $this->ranges[0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bIdx = 0; $bStart = $other->ranges[0]; $bEnd = $other->ranges[1]; } + + while ($aHasMore) { + if (!$bHasMore || $aEnd < $bStart) { + $ranges[] = $aStart; $ranges[] = $aEnd; + $count += $aEnd - $aStart + 1; + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + } elseif ($bEnd < $aStart) { + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } else { + if ($aStart < $bStart) { + $ranges[] = $aStart; $ranges[] = $bStart - 1; + $count += $bStart - $aStart; + } + if ($aEnd <= $bEnd) { + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + } else { + $aStart = $bEnd + 1; + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } + } + } + + return $this->init($ranges, $count); + } + + /** + * Retain only the ranges present in both this set and another. + * Uses a two-pointer sweep for O(m+n) performance. + */ + public function intersect(IntRangeSet $other): self + { + $ranges = []; + $count = 0; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -2; + if ($aHasMore) { $aIdx = 0; $aStart = $this->ranges[0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bIdx = 0; $bStart = $other->ranges[0]; $bEnd = $other->ranges[1]; } + + while ($aHasMore && $bHasMore) { + if ($aEnd < $bStart) { + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + } elseif ($bEnd < $aStart) { + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } else { + $iStart = $aStart > $bStart ? $aStart : $bStart; + $iEnd = $aEnd < $bEnd ? $aEnd : $bEnd; + $ranges[] = $iStart; $ranges[] = $iEnd; + $count += $iEnd - $iStart + 1; + + if ($aEnd < $bEnd) { + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + } elseif ($bEnd < $aEnd) { + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } else { + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } + } + } + } + + return $this->init($ranges, $count); + } + + /** + * Compare two sets in a single pass, returning three disjoint sets: + * [0] removed: ranges present in $a but not $b + * [1] unchanged: ranges present in both $a and $b + * [2] added: ranges present in $b but not $a + * + * Together the three sets account for every integer in $a or $b exactly once. + * + * @return array{0: IntRangeSet, 1: IntRangeSet, 2: IntRangeSet} + */ + public static function diff(IntRangeSet $a, IntRangeSet $b): array + { + $removedRanges = []; $removedCount = 0; + $unchangedRanges = []; $unchangedCount = 0; + $addedRanges = []; $addedCount = 0; + + $ac = count($a->ranges) - 2; + $bc = count($b->ranges) - 2; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -2; + if ($aHasMore) { $aIdx = 0; $aStart = $a->ranges[0]; $aEnd = $a->ranges[1]; } + if ($bHasMore) { $bIdx = 0; $bStart = $b->ranges[0]; $bEnd = $b->ranges[1]; } + + while ($aHasMore || $bHasMore) { + if (!$bHasMore || ($aHasMore && $aEnd < $bStart)) { + $removedRanges[] = $aStart; $removedRanges[] = $aEnd; + $removedCount += $aEnd - $aStart + 1; + if ($aHasMore = $aIdx < $ac) { $aStart = $a->ranges[$aIdx += 2]; $aEnd = $a->ranges[$aIdx + 1]; } + } elseif (!$aHasMore || $bEnd < $aStart) { + $addedRanges[] = $bStart; $addedRanges[] = $bEnd; + $addedCount += $bEnd - $bStart + 1; + if ($bHasMore = $bIdx < $bc) { $bStart = $b->ranges[$bIdx += 2]; $bEnd = $b->ranges[$bIdx + 1]; } + } else { + if ($aStart < $bStart) { + $removedRanges[] = $aStart; $removedRanges[] = $bStart - 1; + $removedCount += $bStart - $aStart; + $uStart = $bStart; + } elseif ($bStart < $aStart) { + $addedRanges[] = $bStart; $addedRanges[] = $aStart - 1; + $addedCount += $aStart - $bStart; + $uStart = $aStart; + } else { + $uStart = $aStart; + } + + $uEnd = $aEnd < $bEnd ? $aEnd : $bEnd; + $unchangedRanges[] = $uStart; $unchangedRanges[] = $uEnd; + $unchangedCount += $uEnd - $uStart + 1; + + // Determine which sides are consumed by the overlap + $aConsumed = $bConsumed = false; + if ($aEnd < $bEnd) { $bStart = $aEnd + 1; $aConsumed = true; } + elseif ($bEnd < $aEnd) { $aStart = $bEnd + 1; $bConsumed = true; } + else { $aConsumed = $bConsumed = true; } + + if ($aConsumed) { + if ($aHasMore = $aIdx < $ac) { $aStart = $a->ranges[$aIdx += 2]; $aEnd = $a->ranges[$aIdx + 1]; } + } + if ($bConsumed) { + if ($bHasMore = $bIdx < $bc) { $bStart = $b->ranges[$bIdx += 2]; $bEnd = $b->ranges[$bIdx + 1]; } + } + } + } + + return [ + self::create($removedRanges, $removedCount), + self::create($unchangedRanges, $unchangedCount), + self::create($addedRanges, $addedCount), + ]; + } + + /** + * Iterate over every integer in the set in ascending order. + */ + public function getIterator(): \Traversable + { + for ($i = 0, $c = count($this->ranges); $i < $c; $i += 2) { + for ($n = $this->ranges[$i]; $n <= $this->ranges[$i + 1]; $n++) { + yield $n; + } + } + } + + /** + * Check whether a value is present in the set. + */ + public function contains(int $n): bool + { + $i = $this->lowerBound($n); + return $i < count($this->ranges) && $this->ranges[$i] <= $n; + } + + /** + * Return the total count of integers in the set. + */ + public function count(): int + { + return $this->count; + } + + /** + * Render the set as an IMAP-style UID set string, e.g. "1:5,7,10:15" + * Single-element ranges are shown as just the number (e.g. "7" not "7:7"). + */ + public function __toString(): string + { + $parts = []; + for ($i = 0, $c = count($this->ranges); $i < $c; $i += 2) { + $parts[] = $this->ranges[$i] === $this->ranges[$i + 1] + ? (string)$this->ranges[$i] + : "{$this->ranges[$i]}:{$this->ranges[$i + 1]}"; + } + return implode(',', $parts); + } +} diff --git a/lib/Horde/ActiveSync/benchmark.php b/lib/Horde/ActiveSync/benchmark.php new file mode 100644 index 00000000..2953496d --- /dev/null +++ b/lib/Horde/ActiveSync/benchmark.php @@ -0,0 +1,443 @@ + $max) break; + $ranges[] = [$start, $end]; + $pos = $end + 1; + } + return $ranges; +} + +function makeIntRangeSet(array $ranges): IntRangeSet +{ + $s = new IntRangeSet(); + foreach ($ranges as [$start, $end]) $s->addRange($start, $end); + return $s; +} + +function makeIntArray(array $ranges): array +{ + $arr = []; + foreach ($ranges as [$start, $end]) + for ($i = $start; $i <= $end; $i++) $arr[] = $i; + return $arr; +} + +function makeUidString(array $ranges): string +{ + return implode(',', array_map( + fn($r) => $r[0] === $r[1] ? (string)$r[0] : "{$r[0]}:{$r[1]}", + $ranges + )); +} + +// --------------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------------- + +$ranges = makeRanges(500, 10, 100_000); // ~500 ranges, ~5000 integers +$rangesLarge = makeRanges(500, 100, 1_000_000); // ~500 ranges, ~50000 integers +$rangesA = makeRanges(500, 100, 1_000_000); +$rangesB = makeRanges(500, 100, 1_000_000); + +// --------------------------------------------------------------------------- +// 1. Build +// --------------------------------------------------------------------------- + +section('Build (500 ranges, ~5000 integers)'); + +benchmark('IntRangeSet::addRange()', fn() => null, function() use ($ranges) { + makeIntRangeSet($ranges); +}); + +benchmark('array push + sort', fn() => null, function() use ($ranges) { + $arr = makeIntArray($ranges); + sort($arr); +}); + +// --------------------------------------------------------------------------- +// 2. Memory footprint +// --------------------------------------------------------------------------- + +section('Memory footprint (500 ranges, ~5000 integers)'); + +$memBefore = mem(); $set = makeIntRangeSet($ranges); $memSet = mem() - $memBefore; +$memBefore = mem(); $arr = makeIntArray($ranges); $memArr = mem() - $memBefore; +printMemory('IntRangeSet', $memSet); +printMemory('int array', $memArr); +unset($set, $arr); + +section('Memory footprint — dense set (1 range, 1000000 integers)'); + +$memBefore = mem(); $set = (new IntRangeSet())->addRange(1, 1_000_000); $memSet = mem() - $memBefore; +$memBefore = mem(); $arr = range(1, 1_000_000); $memArr = mem() - $memBefore; +printMemory('IntRangeSet (1 range)', $memSet); +printMemory('int array (1M items)', $memArr); +unset($set, $arr); + +// --------------------------------------------------------------------------- +// 3. contains() +// --------------------------------------------------------------------------- + +section('contains() — 1000 random lookups'); + +$lookups = array_map(fn() => rand(1, 100_000), range(1, 1000)); + +benchmark('IntRangeSet::contains()', fn() => makeIntRangeSet($ranges), function($set) use ($lookups) { + foreach ($lookups as $n) $set->contains($n); +}); + +benchmark('in_array()', fn() => makeIntArray($ranges), function($arr) use ($lookups) { + foreach ($lookups as $n) in_array($n, $arr); +}); + +benchmark('array binary search', fn() => makeIntArray($ranges), function($arr) use ($lookups) { + foreach ($lookups as $n) { + $lo = 0; $hi = count($arr) - 1; + while ($lo <= $hi) { + $mid = ($lo + $hi) >> 1; + if ($arr[$mid] < $n) $lo = $mid + 1; + elseif ($arr[$mid] > $n) $hi = $mid - 1; + else break; + } + } +}); + +section('contains() — 1000 sequential lookups'); + +$sequential = range(1, 1000); + +benchmark('IntRangeSet::contains() sequential', fn() => makeIntRangeSet($ranges), function($set) use ($sequential) { + foreach ($sequential as $n) $set->contains($n); +}); + +benchmark('array binary search sequential', fn() => makeIntArray($ranges), function($arr) use ($sequential) { + foreach ($sequential as $n) { + $lo = 0; $hi = count($arr) - 1; + while ($lo <= $hi) { + $mid = ($lo + $hi) >> 1; + if ($arr[$mid] < $n) $lo = $mid + 1; + elseif ($arr[$mid] > $n) $hi = $mid - 1; + else break; + } + } +}); + +// --------------------------------------------------------------------------- +// 4. Iteration +// --------------------------------------------------------------------------- + +section('Iteration over all elements'); + +benchmark('IntRangeSet::getIterator()', fn() => makeIntRangeSet($ranges), function($set) { + $sum = 0; + foreach ($set as $v) $sum += $v; +}); + +benchmark('array foreach', fn() => makeIntArray($ranges), function($arr) { + $sum = 0; + foreach ($arr as $v) $sum += $v; +}); + +// --------------------------------------------------------------------------- +// 5. count() +// --------------------------------------------------------------------------- + +section('count() x1000'); + +benchmark('IntRangeSet::count()', fn() => makeIntRangeSet($ranges), function($set) { + for ($i = 0; $i < 1000; $i++) $set->count(); +}); + +benchmark('count(array)', fn() => makeIntArray($ranges), function($arr) { + for ($i = 0; $i < 1000; $i++) count($arr); +}); + +// --------------------------------------------------------------------------- +// 6. add/remove individual values +// --------------------------------------------------------------------------- + +section('add() / remove() — 1000 individual values'); + +$values = array_map(fn() => rand(1, 100_000), range(1, 1000)); + +benchmark('IntRangeSet::add() x1000', fn() => makeIntRangeSet($ranges), function($set) use ($values) { + foreach ($values as $v) $set->add($v); +}); + +benchmark('array_push + sort x1000', fn() => makeIntArray($ranges), function($arr) use ($values) { + foreach ($values as $v) { + if (!in_array($v, $arr)) { $arr[] = $v; sort($arr); } + } +}); + +benchmark('IntRangeSet::remove() x1000', fn() => makeIntRangeSet($ranges), function($set) use ($values) { + foreach ($values as $v) $set->remove($v); +}); + +benchmark('array_search + unset x1000', fn() => makeIntArray($ranges), function($arr) use ($values) { + foreach ($values as $v) { + $k = array_search($v, $arr); + if ($k !== false) array_splice($arr, $k, 1); + } +}); + +// --------------------------------------------------------------------------- +// 7. Worst-case addRange() — reverse order +// --------------------------------------------------------------------------- + +section('addRange() — reverse order (worst case for splice)'); + +$reversedRanges = array_reverse($ranges); + +benchmark('IntRangeSet::addRange() reverse', fn() => null, function() use ($reversedRanges) { + makeIntRangeSet($reversedRanges); +}); + +benchmark('IntRangeSet::addRange() forward', fn() => null, function() use ($ranges) { + makeIntRangeSet($ranges); +}); + +// --------------------------------------------------------------------------- +// 8. Large number of tiny ranges +// --------------------------------------------------------------------------- + +section('Large number of tiny ranges (10000 single-element ranges)'); + +$singleRanges = array_map(fn($i) => [$i * 2, $i * 2], range(1, 10_000)); + +benchmark('IntRangeSet build 10000 singles', fn() => null, function() use ($singleRanges) { + makeIntRangeSet($singleRanges); +}); + +benchmark('array build 10000 singles', fn() => null, function() use ($singleRanges) { + makeIntArray($singleRanges); +}); + +// --------------------------------------------------------------------------- +// 9. removeRange() spanning many ranges +// --------------------------------------------------------------------------- + +section('removeRange() spanning many ranges'); + +benchmark('IntRangeSet::removeRange() spanning all', fn() => makeIntRangeSet($ranges), function($set) { + $set->removeRange(0, 100_000); +}); + +benchmark('array_filter equivalent', fn() => makeIntArray($ranges), function($arr) { + array_filter($arr, fn($v) => $v < 0 || $v > 100_000); +}); + +// --------------------------------------------------------------------------- +// 10. subtract() +// --------------------------------------------------------------------------- + +section('subtract() vs array_diff()'); + +benchmark('IntRangeSet::subtract()', fn() => [makeIntRangeSet($rangesA), makeIntRangeSet($rangesB)], function($data) { + [$a, $b] = $data; + $a->subtract($b); +}); + +benchmark('array_diff()', fn() => [makeIntArray($rangesA), makeIntArray($rangesB)], function($data) { + [$a, $b] = $data; + array_diff($a, $b); +}); + +// --------------------------------------------------------------------------- +// 11. union() / intersect() +// --------------------------------------------------------------------------- + +section('union() vs array_merge+sort+unique'); + +benchmark('IntRangeSet::union()', fn() => [makeIntRangeSet($rangesA), makeIntRangeSet($rangesB)], function($data) { + [$a, $b] = $data; + $a->union($b); +}); + +benchmark('array_merge+unique+sort', fn() => [makeIntArray($rangesA), makeIntArray($rangesB)], function($data) { + [$a, $b] = $data; + $result = array_unique(array_merge($a, $b)); + sort($result); +}); + +section('intersect() vs array_intersect()'); + +benchmark('IntRangeSet::intersect()', fn() => [makeIntRangeSet($rangesA), makeIntRangeSet($rangesB)], function($data) { + [$a, $b] = $data; + $a->intersect($b); +}); + +benchmark('array_intersect()', fn() => [makeIntArray($rangesA), makeIntArray($rangesB)], function($data) { + [$a, $b] = $data; + array_intersect($a, $b); +}); + +// --------------------------------------------------------------------------- +// 12. diff() +// --------------------------------------------------------------------------- + +section('diff() — identical sets'); + +benchmark('IntRangeSet::diff() identical', fn() => [makeIntRangeSet($rangesA), makeIntRangeSet($rangesA)], function($data) { + [$a, $b] = $data; + IntRangeSet::diff($a, $b); +}); + +section('diff() — completely disjoint sets'); + +$rangesC = makeRanges(500, 10, 50_000); +$rangesD = array_map(fn($r) => [$r[0] + 60_000, $r[1] + 60_000], makeRanges(500, 10, 50_000)); + +benchmark('IntRangeSet::diff() disjoint', fn() => [makeIntRangeSet($rangesC), makeIntRangeSet($rangesD)], function($data) { + [$a, $b] = $data; + IntRangeSet::diff($a, $b); +}); + +benchmark('array_diff() disjoint', fn() => [makeIntArray($rangesC), makeIntArray($rangesD)], function($data) { + [$a, $b] = $data; + array_diff($a, $b); + array_diff($b, $a); + array_intersect($a, $b); +}); + +section('diff() — large overlapping sets (~50000 integers each)'); + +benchmark('IntRangeSet::diff()', fn() => [makeIntRangeSet($rangesLarge), makeIntRangeSet($rangesLarge)], function($data) { + [$a, $b] = $data; + IntRangeSet::diff($a, $b); +}); + +benchmark('array_diff() on int arrays', fn() => [makeIntArray($rangesLarge), makeIntArray($rangesLarge)], function($data) { + [$a, $b] = $data; + array_diff($a, $b); + array_diff($b, $a); + array_intersect($a, $b); +}); + +// --------------------------------------------------------------------------- +// 13. constructor string parsing +// --------------------------------------------------------------------------- + +section('constructor string parsing'); + +$uidString = makeUidString($ranges); +$uidStringUnsorted = makeUidString(array_reverse($ranges)); + +benchmark('new IntRangeSet() ~500 ranges sorted', fn() => null, function() use ($uidString) { + new IntRangeSet($uidString); +}); + +benchmark('new IntRangeSet() ~500 ranges unsorted', fn() => null, function() use ($uidStringUnsorted) { + new IntRangeSet($uidStringUnsorted); +}); + +benchmark('explode+array build ~500 ranges sorted', fn() => null, function() use ($uidString) { + $arr = []; + foreach (explode(',', $uidString) as $part) { + $m = explode(':', $part); + $start = (int)$m[0]; $end = isset($m[1]) ? (int)$m[1] : $start; + for ($i = $start; $i <= $end; $i++) $arr[] = $i; + } +}); + +benchmark('explode+array build ~500 ranges unsorted+dedup', fn() => null, function() use ($uidStringUnsorted) { + $arr = []; + foreach (explode(',', $uidStringUnsorted) as $part) { + $m = explode(':', $part); + $start = (int)$m[0]; $end = isset($m[1]) ? (int)$m[1] : $start; + for ($i = $start; $i <= $end; $i++) $arr[] = $i; + } + $arr = array_unique($arr); + sort($arr); +}); + +// --------------------------------------------------------------------------- +// 14. Simulated IMAP sync +// --------------------------------------------------------------------------- + +section('Simulated IMAP sync (parse, diff, add, remove)'); + +$prevRanges = makeRanges(400, 10, 100_000); +$newRanges = makeRanges(400, 10, 100_000); +$prevString = makeUidString($prevRanges); +$newString = makeUidString($newRanges); + +benchmark('IntRangeSet IMAP sync', fn() => null, function() use ($prevString, $newString) { + $prev = new IntRangeSet($prevString); + $curr = new IntRangeSet($newString); + [$removed, $unchanged, $added] = IntRangeSet::diff($prev, $curr); + $result = clone $prev; + $result->subtract($removed)->union($added); +}); + +benchmark('array IMAP sync', fn() => null, function() use ($prevRanges, $newRanges) { + $prev = makeIntArray($prevRanges); + $curr = makeIntArray($newRanges); + $removed = array_diff($prev, $curr); + $added = array_diff($curr, $prev); + $result = array_diff($prev, $removed); + $result = array_merge($result, $added); + sort($result); +}); + +echo "\n"; diff --git a/lib/Horde/ActiveSync/tests.php b/lib/Horde/ActiveSync/tests.php new file mode 100644 index 00000000..c489a619 --- /dev/null +++ b/lib/Horde/ActiveSync/tests.php @@ -0,0 +1,556 @@ +add(5); +check('add single', (string)$s, '5'); + +$s->add(10); +check('add non-adjacent', (string)$s, '5,10'); + +$s->add(6); +check('add adjacent right', (string)$s, '5:6,10'); + +$s->add(4); +check('add adjacent left', (string)$s, '4:6,10'); + +$s->add(5); +check('add duplicate', (string)$s, '4:6,10'); + +$s->add(7); $s->add(8); $s->add(9); +check('bridge two ranges', (string)$s, '4:10'); + +$s->addRange(6, 8); +check('addRange inside existing', (string)$s, '4:10'); + +$s->addRange(1, 4); +check('addRange extending left', (string)$s, '1:10'); + +$s->addRange(10, 13); +check('addRange extending right', (string)$s, '1:13'); + +$s->addRange(-2, 0); +check('addRange adjacent left boundary', (string)$s, '-2:13'); + +$s->addRange(14, 16); +check('addRange adjacent right boundary', (string)$s, '-2:16'); + +$s = new IntRangeSet(); +$s->addRange(1, 3)->addRange(6, 8)->addRange(11, 13); +check('setup three ranges', (string)$s, '1:3,6:8,11:13'); +$s->addRange(2, 12); +check('addRange spanning multiple ranges', (string)$s, '1:13'); + +$s = new IntRangeSet(); +$s->addRange(7, 7); +check('addRange single value', (string)$s, '7'); + +$s = new IntRangeSet(); +$s->addRange(10, 20); +$s->addRange(1, 3); +check('addRange before all', (string)$s, '1:3,10:20'); + +$s->addRange(25, 30); +check('addRange after all', (string)$s, '1:3,10:20,25:30'); + +$s = new IntRangeSet(); +$s->addRange(10, 5); +check('addRange reversed', (string)$s, '5:10'); + +$s = new IntRangeSet(); +$s->addRange(1, 20); +$s->removeRange(15, 5); +check('removeRange reversed', (string)$s, '1:4,16:20'); + +// constructor accepts reversed range +$s = new IntRangeSet('10:5'); +check('constructor reversed accepted', (string)$s, '5:10'); + +// constructor invalid input throws +try { + new IntRangeSet('abc'); + check('constructor invalid throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('constructor invalid throws', 'exception', 'exception'); +} + +// ------------------------------------------------------------------------- +echo "\n=== remove() / removeRange() ===\n"; + +$s = new IntRangeSet(); +$s->remove(5); +check('remove from empty', (string)$s, ''); + +$s->addRange(1, 10); +$s->remove(15); +check('remove non-existent', (string)$s, '1:10'); + +$s->remove(1); +check('remove start', (string)$s, '2:10'); + +$s->remove(10); +check('remove end', (string)$s, '2:9'); + +$s->remove(5); +check('remove middle splits', (string)$s, '2:4,6:9'); + +$s = new IntRangeSet(); +$s->add(7); +$s->remove(7); +check('remove sole element', (string)$s, ''); + +$s = new IntRangeSet(); +$s->addRange(1, 20); +$s->removeRange(8, 12); +check('removeRange inside splits', (string)$s, '1:7,13:20'); + +$s = new IntRangeSet(); +$s->addRange(5, 10); +$s->removeRange(5, 10); +check('removeRange exact match', (string)$s, ''); + +$s = new IntRangeSet(); +$s->addRange(5, 10); +$s->removeRange(3, 7); +check('removeRange overlapping left', (string)$s, '8:10'); + +$s = new IntRangeSet(); +$s->addRange(5, 10); +$s->removeRange(8, 13); +check('removeRange overlapping right', (string)$s, '5:7'); + +$s = new IntRangeSet(); +$s->addRange(1, 3)->addRange(6, 8)->addRange(11, 13); +$s->removeRange(1, 13); +check('removeRange wipes multiple', (string)$s, ''); + +$s = new IntRangeSet(); +$s->addRange(1, 5)->addRange(10, 15)->addRange(20, 25); +$s->removeRange(3, 22); +check('removeRange partial trim both ends', (string)$s, '1:2,23:25'); + +$s = new IntRangeSet(); +$s->addRange(10, 20); +$s->removeRange(1, 5); +check('removeRange before all', (string)$s, '10:20'); + +$s->removeRange(25, 30); +check('removeRange after all', (string)$s, '10:20'); + +$s = new IntRangeSet(); +$s->addRange(1, 10); +$s->removeRange(5, 5); +check('removeRange single value', (string)$s, '1:4,6:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->subtract(new IntRangeSet()); +check('diff partial remainder a after b exhausted (setup)', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 20); +$b = new IntRangeSet(); +$b->addRange(1, 10); +checkDiff('diff partial remainder a after b exhausted', + IntRangeSet::diff($a, $b), + '11:20', '1:10', ''); + +checkDiff('diff single values disjoint', + IntRangeSet::diff(new IntRangeSet('1,3,5'), new IntRangeSet('2,4,6')), + '1,3,5', '', '2,4,6'); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->subtract($a); +check('subtract self', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(3, 7); +$a->subtract($b); +checkInt('count after subtract', $a->count(), 5); + +// ------------------------------------------------------------------------- +echo "\n=== constructor / __toString() ===\n"; + +$s = new IntRangeSet(''); +check('constructor empty', (string)$s, ''); + +$s = new IntRangeSet('7'); +check('constructor single value', (string)$s, '7'); + +$s = new IntRangeSet('1:5'); +check('constructor single range', (string)$s, '1:5'); + +$s = new IntRangeSet('1:5,7,10:15'); +check('constructor mixed', (string)$s, '1:5,7,10:15'); + +$original = '1:5,7,10:15'; +$s = new IntRangeSet($original); +check('round-trip simple', (string)$s, $original); + +$s = new IntRangeSet('1:5,3:8'); +check('constructor merges overlapping', (string)$s, '1:8'); + +$s = new IntRangeSet('1:5,6:10'); +check('constructor merges adjacent', (string)$s, '1:10'); + +try { + new IntRangeSet(':'); + check('constructor colon only throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('constructor colon only throws', 'exception', 'exception'); +} + +try { + new IntRangeSet('1,,3'); + check('constructor empty part throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('constructor empty part throws', 'exception', 'exception'); +} + +// ------------------------------------------------------------------------- +echo "\n=== union() ===\n"; + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->union(new IntRangeSet()); +check('union with empty', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->union($b); +check('union from empty', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(10, 15); +$a->union($b); +check('union non-overlapping', (string)$a, '1:5,10:15'); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(5, 15); +$a->union($b); +check('union overlapping', (string)$a, '1:15'); + +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(6, 10); +$a->union($b); +check('union adjacent', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->union($a); +check('union with self', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 5)->addRange(8, 12); +$b = new IntRangeSet(); +$b->addRange(6, 9); +$a->union($b); +check('union cross-side merge', (string)$a, '1:12'); + +// ------------------------------------------------------------------------- +echo "\n=== intersect() ===\n"; + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->intersect(new IntRangeSet()); +check('intersect with empty', (string)$a, ''); + +$a = new IntRangeSet(); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->intersect($b); +check('intersect from empty', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->intersect($b); +check('intersect identical', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(10, 15); +$a->intersect($b); +check('intersect non-overlapping', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(5, 15); +$a->intersect($b); +check('intersect partial overlap', (string)$a, '5:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 20); +$b = new IntRangeSet(); +$b->addRange(5, 10); +$a->intersect($b); +check('intersect a contains b', (string)$a, '5:10'); + +$a = new IntRangeSet(); +$a->addRange(1, 5)->addRange(10, 15)->addRange(20, 25); +$b = new IntRangeSet(); +$b->addRange(3, 12)->addRange(22, 30); +$a->intersect($b); +check('intersect multiple ranges', (string)$a, '3:5,10:12,22:25'); + +// ------------------------------------------------------------------------- +echo "\n=== subtract() ===\n"; + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->subtract(new IntRangeSet()); +check('subtract empty set', (string)$a, '1:10'); + +$a = new IntRangeSet(); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->subtract($b); +check('subtract from empty set', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->subtract($b); +check('subtract identical set', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(7, 10); +$a->subtract($b); +check('subtract non-overlapping', (string)$a, '1:5'); + +$a = new IntRangeSet(); +$a->addRange(1, 20); +$b = new IntRangeSet(); +$b->addRange(8, 12); +$a->subtract($b); +check('subtract splits range', (string)$a, '1:7,13:20'); + +$a = new IntRangeSet(); +$a->addRange(5, 10); +$b = new IntRangeSet(); +$b->addRange(1, 20); +$a->subtract($b); +check('subtract superset', (string)$a, ''); + +$a = new IntRangeSet(); +$a->addRange(1, 30); +$b = new IntRangeSet(); +$b->addRange(5, 8)->addRange(12, 15)->addRange(20, 25); +$a->subtract($b); +check('subtract multiple from one', (string)$a, '1:4,9:11,16:19,26:30'); + +$a = new IntRangeSet(); +$a->addRange(1, 5)->addRange(8, 12)->addRange(15, 20); +$b = new IntRangeSet(); +$b->addRange(3, 17); +$a->subtract($b); +check('subtract one from multiple', (string)$a, '1:2,18:20'); + +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(3, 7); +$a->subtract($b); +check('other set unchanged', (string)$b, '3:7'); + +// ------------------------------------------------------------------------- +echo "\n=== diff() ===\n"; + +checkDiff('both empty', + IntRangeSet::diff(new IntRangeSet(), new IntRangeSet()), + '', '', ''); + +checkDiff('a empty', + IntRangeSet::diff(new IntRangeSet(), new IntRangeSet('1:5')), + '', '', '1:5'); + +checkDiff('b empty', + IntRangeSet::diff(new IntRangeSet('1:5'), new IntRangeSet()), + '1:5', '', ''); + +checkDiff('identical sets', + IntRangeSet::diff(new IntRangeSet('1:5,10:15'), new IntRangeSet('1:5,10:15')), + '', '1:5,10:15', ''); + +checkDiff('disjoint a before b', + IntRangeSet::diff(new IntRangeSet('1:5'), new IntRangeSet('10:15')), + '1:5', '', '10:15'); + +checkDiff('disjoint b before a', + IntRangeSet::diff(new IntRangeSet('10:15'), new IntRangeSet('1:5')), + '10:15', '', '1:5'); + +checkDiff('partial overlap a extends left', + IntRangeSet::diff(new IntRangeSet('1:10'), new IntRangeSet('5:15')), + '1:4', '5:10', '11:15'); + +checkDiff('partial overlap b extends left', + IntRangeSet::diff(new IntRangeSet('5:15'), new IntRangeSet('1:10')), + '11:15', '5:10', '1:4'); + +checkDiff('a contains b', + IntRangeSet::diff(new IntRangeSet('1:20'), new IntRangeSet('5:10')), + '1:4,11:20', '5:10', ''); + +checkDiff('b contains a', + IntRangeSet::diff(new IntRangeSet('5:10'), new IntRangeSet('1:20')), + '', '5:10', '1:4,11:20'); + +checkDiff('interleaved ranges', + IntRangeSet::diff(new IntRangeSet('1:5,11:15,21:25'), new IntRangeSet('3:13,23:30')), + '1:2,14:15,21:22', '3:5,11:13,23:25', '6:10,26:30'); + +checkDiff('single values overlap', + IntRangeSet::diff(new IntRangeSet('1,2,3'), new IntRangeSet('2,3,4')), + '1', '2:3', '4'); + +checkDiff('diff partial remainder carried across iterations', + IntRangeSet::diff(new IntRangeSet('1:30'), new IntRangeSet('5:10,15:20,25:30')), + '1:4,11:14,21:24', '5:10,15:20,25:30', ''); + +// ------------------------------------------------------------------------- +echo "\n=== getIterator() ===\n"; + +$result = []; +foreach (new IntRangeSet() as $v) $result[] = $v; +check('iterate empty', implode(',', $result), ''); + +$result = []; +foreach (new IntRangeSet('5') as $v) $result[] = $v; +check('iterate single value', implode(',', $result), '5'); + +$result = []; +foreach (new IntRangeSet('1:5') as $v) $result[] = $v; +check('iterate single range', implode(',', $result), '1,2,3,4,5'); + +$result = []; +foreach (new IntRangeSet('1:3,7,10:12') as $v) $result[] = $v; +check('iterate multiple ranges', implode(',', $result), '1,2,3,7,10,11,12'); + +$s = new IntRangeSet(); +$s->addRange(5, 7)->addRange(1, 3); +$result = []; +foreach ($s as $v) $result[] = $v; +check('iterate ascending order', implode(',', $result), '1,2,3,5,6,7'); + +$s = new IntRangeSet(); +$s->addRange(-3, -1); +$result = []; +foreach ($s as $v) $result[] = $v; +check('iterate negative numbers', implode(',', $result), '-3,-2,-1'); + +checkDiff('diff partial remainder carried across iterations', + IntRangeSet::diff(new IntRangeSet('1:30'), new IntRangeSet('5:10,15:20,25:30')), + '1:4,11:14,21:24', '5:10,15:20,25:30', ''); + +// ------------------------------------------------------------------------- +echo "\n=== contains() ===\n"; + +$s = new IntRangeSet('1:5,10:15'); +checkBool('contains inside first range', $s->contains(3), true); +checkBool('contains start of range', $s->contains(1), true); +checkBool('contains end of range', $s->contains(5), true); +checkBool('contains inside second range', $s->contains(12), true); +checkBool('contains gap between ranges', $s->contains(7), false); +checkBool('contains before all ranges', $s->contains(0), false); +checkBool('contains after all ranges', $s->contains(16), false); + +// ------------------------------------------------------------------------- +echo "\n=== count() ===\n"; + +$s = new IntRangeSet(); +checkInt('count empty', $s->count(), 0); +$s->add(5); +checkInt('count single', $s->count(), 1); +$s->addRange(1, 3); +checkInt('count after addRange', $s->count(), 4); +$s->addRange(10, 14); +checkInt('count two ranges', $s->count(), 9); +$s->removeRange(2, 11); +checkInt('count after removeRange', $s->count(), 4); + +// ------------------------------------------------------------------------- +echo "\n=== Results ===\n"; +echo "Passed: $pass\n"; +echo "Failed: $fail\n";