From 069d97e12f47e7c393cc23a532624de8ae4f760b Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Mon, 6 Apr 2026 17:09:09 -0400 Subject: [PATCH 1/3] DRAFT: Implement IntRangeSet class to be used for IMAP ActiveSync --- lib/Horde/ActiveSync/IntRangeSet.php | 453 +++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 lib/Horde/ActiveSync/IntRangeSet.php diff --git a/lib/Horde/ActiveSync/IntRangeSet.php b/lib/Horde/ActiveSync/IntRangeSet.php new file mode 100644 index 00000000..ca672619 --- /dev/null +++ b/lib/Horde/ActiveSync/IntRangeSet.php @@ -0,0 +1,453 @@ + + */ +class IntRangeSet implements \IteratorAggregate +{ + /** + * @param array $ranges Sorted list of [start, end] pairs + * @param int $count Cached total count of integers in the set + */ + public function __construct( + private array $ranges = [], + private int $count = 0, + ) {} + + /** + * Return the index of the first range whose end >= $n, or count($this->ranges) + * if no such range exists. Search starts at $lo. + * + * Early exits: + * - If $lo > last index or last range ends before $n, return past-the-end. + * - If the first candidate range already covers $n, return $lo immediately. + */ + private function lowerBound(int $n, int $lo = 0): int + { + $hi = count($this->ranges) - 1; + + if ($hi < $lo || $this->ranges[$hi][1] < $n) return $hi + 1; + if ($this->ranges[$lo][1] >= $n) return $lo; + + do { + $mid = ($lo + $hi) >> 1; + [, $midEnd] = $this->ranges[$mid]; + if ($midEnd < $n) $lo = $mid + 1; + elseif ($midEnd === $n) return $mid; + else $hi = $mid - 1; + } while ($lo <= $hi); + + return $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. + * + * @param int $lo Internal hint for lowerBound start index; do not use externally. + */ + public function addRange(int $start, int $end, int $lo = 0): self + { + if ($start > $end) [$start, $end] = [$end, $start]; + + $c = count($this->ranges); + + // First range whose end >= our start (could be adjacent or overlapping) + $first = $this->lowerBound($start, $lo); + + // First range whose end >= our end (could be adjacent or overlapping) + $last = $start === $end ? $first : $this->lowerBound($end, $first); + + // Absorb right neighbor if adjacent or overlapping + if ($last < $c) { + [$lastStart, $lastEnd] = $this->ranges[$last]; + if ($lastStart <= $end + 1) { + $end = $lastEnd; + $last++; + } + } + + // Check left neighbor for adjacency + if ($first > 0) { + [, $prevEnd] = $this->ranges[$first - 1]; + if ($prevEnd + 1 >= $start) --$first; + } + + // Absorb left neighbor's start if it extends further left + if ($first < $c) { + [$firstStart] = $this->ranges[$first]; + $start = min($start, $firstStart); + } + + // Compute count delta: subtract absorbed ranges, add new range + $delta = $end - $start + 1; + for ($k = $first; $k < $last; $k++) $delta -= $this->ranges[$k][1] - $this->ranges[$k][0] + 1; + $this->count += $delta; + + if ($last - $first === 1) { + $this->ranges[$first] = [$start, $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 range whose end >= start (first potentially affected range) + $first = $this->lowerBound($start); + + // No ranges are affected + if ($first >= $c || $this->ranges[$first][0] > $end) { + return $this; + } + + // First range whose end >= end (last potentially affected range) + $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, $start - 1]; + } + + // If the last affected range ends after $end, preserve its right portion + if ($last < $c) { + [, $lastEnd] = $this->ranges[$last]; + if ($lastEnd > $end) { + $replacement[] = [$end + 1, $lastEnd]; + } + $last++; + } + + // Compute delta: sum of absorbed ranges minus preserved portions + $delta = 0; + for ($k = $first; $k < $last; $k++) $delta += $this->ranges[$k][1] - $this->ranges[$k][0] + 1; + foreach ($replacement as [$rs, $re]) $delta -= $re - $rs + 1; + $this->count -= $delta; + + if ($last - $first === 1 && count($replacement) === 1) { + $this->ranges[$first] = $replacement[0]; + } 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) - 1; + $bc = count($other->ranges) - 1; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -1; + if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + + while ($aHasMore || $bHasMore) { + if (!$bHasMore || ($aHasMore && $aStart <= $bStart)) { + $curStart = $aStart; $curEnd = $aEnd; + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + } else { + $curStart = $bStart; $curEnd = $bEnd; + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } + + do { + $merged = false; + if ($aHasMore && $aStart <= $curEnd + 1) { + if ($aEnd > $curEnd) $curEnd = $aEnd; + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + $merged = true; + } + if ($bHasMore && $bStart <= $curEnd + 1) { + if ($bEnd > $curEnd) $curEnd = $bEnd; + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + $merged = true; + } + } while ($merged); + + $ranges[] = [$curStart, $curEnd]; + $count += $curEnd - $curStart + 1; + } + + $this->ranges = $ranges; + $this->count = $count; + return $this; + } + + /** + * 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) - 1; + $bc = count($other->ranges) - 1; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -1; + if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + + while ($aHasMore) { + if (!$bHasMore || $aEnd < $bStart) { + $ranges[] = [$aStart, $aEnd]; + $count += $aEnd - $aStart + 1; + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + } elseif ($bEnd < $aStart) { + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } else { + if ($aStart < $bStart) { + $ranges[] = [$aStart, $bStart - 1]; + $count += $bStart - $aStart; + } + if ($aEnd <= $bEnd) { + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + } else { + $aStart = $bEnd + 1; + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } + } + } + + $this->ranges = $ranges; + $this->count = $count; + return $this; + } + + /** + * 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) - 1; + $bc = count($other->ranges) - 1; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -1; + if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + + while ($aHasMore && $bHasMore) { + if ($aEnd < $bStart) { + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + } elseif ($bEnd < $aStart) { + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } else { + $iStart = max($aStart, $bStart); + $iEnd = min($aEnd, $bEnd); + $ranges[] = [$iStart, $iEnd]; + $count += $iEnd - $iStart + 1; + + if ($aEnd < $bEnd) { + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + } elseif ($bEnd < $aEnd) { + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } else { + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + } + } + } + + $this->ranges = $ranges; + $this->count = $count; + return $this; + } + + /** + * 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) - 1; + $bc = count($b->ranges) - 1; + $aHasMore = $ac >= 0; + $bHasMore = $bc >= 0; + $aIdx = $bIdx = -1; + if ($aHasMore) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } + if ($bHasMore) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + + while ($aHasMore || $bHasMore) { + if (!$bHasMore || ($aHasMore && $aEnd < $bStart)) { + $removedRanges[] = [$aStart, $aEnd]; + $removedCount += $aEnd - $aStart + 1; + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } + } elseif (!$aHasMore || $bEnd < $aStart) { + $addedRanges[] = [$bStart, $bEnd]; + $addedCount += $bEnd - $bStart + 1; + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + } else { + if ($aStart < $bStart) { + $removedRanges[] = [$aStart, $bStart - 1]; + $removedCount += $bStart - $aStart; + } elseif ($bStart < $aStart) { + $addedRanges[] = [$bStart, $aStart - 1]; + $addedCount += $aStart - $bStart; + } + + $uStart = max($aStart, $bStart); + $uEnd = min($aEnd, $bEnd); + $unchangedRanges[] = [$uStart, $uEnd]; + $unchangedCount += $uEnd - $uStart + 1; + + $aConsumed = false; + $bConsumed = false; + if ($aEnd < $bEnd) { $bStart = $aEnd + 1; $aConsumed = true; } + elseif ($bEnd < $aEnd) { $aStart = $bEnd + 1; $bConsumed = true; } + else { $aConsumed = true; $bConsumed = true; } + + if ($aConsumed) { + if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } + } + if ($bConsumed) { + if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + } + } + } + + return [ + new self($removedRanges, $removedCount), + new self($unchangedRanges, $unchangedCount), + new self($addedRanges, $addedCount), + ]; + } + + /** + * Iterate over every integer in the set in ascending order. + */ + public function getIterator(): \Traversable + { + foreach ($this->ranges as [$start, $end]) { + for ($i = $start; $i <= $end; $i++) { + yield $i; + } + } + } + + /** + * Parse an IMAP-style UID set string, e.g. "1:5,7,10:15" + * and return a new IntRangeSet. + * Only non-negative integers are supported in string format. + * Input may be unsorted or contain overlapping/adjacent ranges. + * + * @throws \InvalidArgumentException on malformed input + */ + public static function fromString(string $s): self + { + $set = new self(); + if ($s === '') return $set; + + $c = 0; + 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; + if ($start <= $end) [$rs, $re] = [$start, $end]; + else [$rs, $re] = [$end, $start]; + if ($c === 0 || $set->ranges[$c - 1][1] < $rs - 1) { + $set->ranges[] = [$rs, $re]; + $set->count += $re - $rs + 1; + $c++; + } else { + $set->addRange($rs, $re, $c - 1); + $c = count($set->ranges); + } + } + + return $set; + } + + /** + * 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][0] <= $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 + { + return implode(',', array_map( + fn($r) => $r[0] === $r[1] ? (string)$r[0] : "{$r[0]}:{$r[1]}", + $this->ranges + )); + } +} From f7bb275ec3918af600dd35f26f2b5d76053d9f07 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Tue, 7 Apr 2026 12:47:40 -0400 Subject: [PATCH 2/3] Switched to flat ranges (this improves memory usage significantly) Added tests.php and benchmark.php (yes, I know they do not belong here, but this is a draft) --- lib/Horde/ActiveSync/IntRangeSet.php | 253 ++++++------ lib/Horde/ActiveSync/benchmark.php | 446 ++++++++++++++++++++ lib/Horde/ActiveSync/tests.php | 591 +++++++++++++++++++++++++++ 3 files changed, 1167 insertions(+), 123 deletions(-) create mode 100644 lib/Horde/ActiveSync/benchmark.php create mode 100644 lib/Horde/ActiveSync/tests.php diff --git a/lib/Horde/ActiveSync/IntRangeSet.php b/lib/Horde/ActiveSync/IntRangeSet.php index ca672619..68da813c 100644 --- a/lib/Horde/ActiveSync/IntRangeSet.php +++ b/lib/Horde/ActiveSync/IntRangeSet.php @@ -4,48 +4,53 @@ error_reporting(E_ALL); /** - * IntRangeSet v0.4 + * IntRangeSet v0.5 * * Maintains an ordered, compact set of integer ranges. - * Ranges are stored as [start, end] pairs in ascending order. + * Ranges are stored as a flat array [start1, end1, start2, end2, ...] + * in ascending order. * * String representation uses IMAP-style syntax: "1:5,7,10:15" * Note: fromString() only supports non-negative integers. * - * @author Dmitry Petrov + * @author: Dmitry Petrov */ class IntRangeSet implements \IteratorAggregate { /** - * @param array $ranges Sorted list of [start, end] pairs - * @param int $count Cached total count of integers in the set + * Flat array of alternating start/end values: [start1, end1, start2, end2, ...] + * @var int[] */ - public function __construct( - private array $ranges = [], - private int $count = 0, - ) {} + private array $ranges; + + /** @var int Cached total count of integers in the set */ + private int $count; + + public function __construct(array $ranges = [], int $count = 0) + { + $this->ranges = $ranges; + $this->count = $count; + } /** - * Return the index of the first range whose end >= $n, or count($this->ranges) - * if no such range exists. Search starts at $lo. - * - * Early exits: - * - If $lo > last index or last range ends before $n, return past-the-end. - * - If the first candidate range already covers $n, return $lo immediately. + * 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. */ private function lowerBound(int $n, int $lo = 0): int { - $hi = count($this->ranges) - 1; + $hi = count($this->ranges) - 2; - if ($hi < $lo || $this->ranges[$hi][1] < $n) return $hi + 1; - if ($this->ranges[$lo][1] >= $n) return $lo; + if ($hi < $lo || $this->ranges[$hi + 1] < $n) return $hi + 2; + if ($this->ranges[$lo + 1] >= $n) return $lo; do { - $mid = ($lo + $hi) >> 1; - [, $midEnd] = $this->ranges[$mid]; - if ($midEnd < $n) $lo = $mid + 1; + // Round down to even index + $mid = (($lo + $hi) >> 1) & ~1; + $midEnd = $this->ranges[$mid + 1]; + if ($midEnd < $n) $lo = $mid + 2; elseif ($midEnd === $n) return $mid; - else $hi = $mid - 1; + else $hi = $mid - 2; } while ($lo <= $hi); return $lo; @@ -70,44 +75,39 @@ public function addRange(int $start, int $end, int $lo = 0): self { if ($start > $end) [$start, $end] = [$end, $start]; - $c = count($this->ranges); - - // First range whose end >= our start (could be adjacent or overlapping) + $c = count($this->ranges); $first = $this->lowerBound($start, $lo); - - // First range whose end >= our end (could be adjacent or overlapping) - $last = $start === $end ? $first : $this->lowerBound($end, $first); + $last = $start === $end ? $first : $this->lowerBound($end, $first); // Absorb right neighbor if adjacent or overlapping if ($last < $c) { - [$lastStart, $lastEnd] = $this->ranges[$last]; + $lastStart = $this->ranges[$last]; if ($lastStart <= $end + 1) { - $end = $lastEnd; - $last++; + $end = $this->ranges[$last + 1]; + $last += 2; } } - // Check left neighbor for adjacency - if ($first > 0) { - [, $prevEnd] = $this->ranges[$first - 1]; - if ($prevEnd + 1 >= $start) --$first; + // Check left neighbor for adjacency ($first - 1 is the end value of 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) { - [$firstStart] = $this->ranges[$first]; - $start = min($start, $firstStart); + 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++) $delta -= $this->ranges[$k][1] - $this->ranges[$k][0] + 1; + for ($k = $first; $k < $last; $k += 2) $delta -= $this->ranges[$k + 1] - $this->ranges[$k] + 1; $this->count += $delta; - if ($last - $first === 1) { - $this->ranges[$first] = [$start, $end]; + if ($last - $first === 2) { + $this->ranges[$first] = $start; + $this->ranges[$first + 1] = $end; } else { - array_splice($this->ranges, $first, $last - $first, [[$start, $end]]); + array_splice($this->ranges, $first, $last - $first, [$start, $end]); } return $this; } @@ -121,44 +121,44 @@ public function removeRange(int $start, int $end): self { if ($start > $end) [$start, $end] = [$end, $start]; - $c = count($this->ranges); - - // First range whose end >= start (first potentially affected range) + $c = count($this->ranges); $first = $this->lowerBound($start); - // No ranges are affected - if ($first >= $c || $this->ranges[$first][0] > $end) { + if ($first >= $c || $this->ranges[$first] > $end) { return $this; } - // First range whose end >= end (last potentially affected range) $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]; + $firstStart = $this->ranges[$first]; if ($firstStart < $start) { - $replacement[] = [$firstStart, $start - 1]; + $replacement[] = $firstStart; + $replacement[] = $start - 1; } // If the last affected range ends after $end, preserve its right portion if ($last < $c) { - [, $lastEnd] = $this->ranges[$last]; + $lastEnd = $this->ranges[$last + 1]; if ($lastEnd > $end) { - $replacement[] = [$end + 1, $lastEnd]; + $replacement[] = $end + 1; + $replacement[] = $lastEnd; } - $last++; + $last += 2; } // Compute delta: sum of absorbed ranges minus preserved portions $delta = 0; - for ($k = $first; $k < $last; $k++) $delta += $this->ranges[$k][1] - $this->ranges[$k][0] + 1; - foreach ($replacement as [$rs, $re]) $delta -= $re - $rs + 1; + 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 === 1 && count($replacement) === 1) { - $this->ranges[$first] = $replacement[0]; + if ($last - $first === 2 && $rc === 2) { + $this->ranges[$first] = $replacement[0]; + $this->ranges[$first + 1] = $replacement[1]; } else { array_splice($this->ranges, $first, $last - $first, $replacement); } @@ -181,38 +181,39 @@ public function union(IntRangeSet $other): self { $ranges = []; $count = 0; - $ac = count($this->ranges) - 1; - $bc = count($other->ranges) - 1; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; - $aIdx = $bIdx = -1; - if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } - if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + $aIdx = $bIdx = -2; + if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } while ($aHasMore || $bHasMore) { if (!$bHasMore || ($aHasMore && $aStart <= $bStart)) { $curStart = $aStart; $curEnd = $aEnd; - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } } else { $curStart = $bStart; $curEnd = $bEnd; - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + 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, $aEnd] = $this->ranges[++$aIdx]; } + 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, $bEnd] = $other->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } $merged = true; } } while ($merged); - $ranges[] = [$curStart, $curEnd]; + $ranges[] = $curStart; + $ranges[] = $curEnd; $count += $curEnd - $curStart + 1; } @@ -229,31 +230,31 @@ public function subtract(IntRangeSet $other): self { $ranges = []; $count = 0; - $ac = count($this->ranges) - 1; - $bc = count($other->ranges) - 1; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; - $aIdx = $bIdx = -1; - if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } - if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + $aIdx = $bIdx = -2; + if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } while ($aHasMore) { if (!$bHasMore || $aEnd < $bStart) { - $ranges[] = [$aStart, $aEnd]; + $ranges[] = $aStart; $ranges[] = $aEnd; $count += $aEnd - $aStart + 1; - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } } elseif ($bEnd < $aStart) { - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } } else { if ($aStart < $bStart) { - $ranges[] = [$aStart, $bStart - 1]; + $ranges[] = $aStart; $ranges[] = $bStart - 1; $count += $bStart - $aStart; } if ($aEnd <= $bEnd) { - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } } else { $aStart = $bEnd + 1; - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } } } } @@ -271,32 +272,32 @@ public function intersect(IntRangeSet $other): self { $ranges = []; $count = 0; - $ac = count($this->ranges) - 1; - $bc = count($other->ranges) - 1; + $ac = count($this->ranges) - 2; + $bc = count($other->ranges) - 2; $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; - $aIdx = $bIdx = -1; - if ($aHasMore) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } - if ($bHasMore) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + $aIdx = $bIdx = -2; + if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } + if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } while ($aHasMore && $bHasMore) { if ($aEnd < $bStart) { - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } } elseif ($bEnd < $aStart) { - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } } else { - $iStart = max($aStart, $bStart); - $iEnd = min($aEnd, $bEnd); - $ranges[] = [$iStart, $iEnd]; + $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, $aEnd] = $this->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $this->ranges[$aIdx += 2]; $aEnd = $this->ranges[$aIdx + 1]; } } elseif ($bEnd < $aEnd) { - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $other->ranges[$bIdx += 2]; $bEnd = $other->ranges[$bIdx + 1]; } } else { - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $this->ranges[++$aIdx]; } - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $other->ranges[++$bIdx]; } + 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]; } } } } @@ -322,35 +323,38 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array $unchangedRanges = []; $unchangedCount = 0; $addedRanges = []; $addedCount = 0; - $ac = count($a->ranges) - 1; - $bc = count($b->ranges) - 1; + $ac = count($a->ranges) - 2; + $bc = count($b->ranges) - 2; $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; - $aIdx = $bIdx = -1; - if ($aHasMore) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } - if ($bHasMore) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + $aIdx = $bIdx = -2; + if ($aHasMore) { $aStart = $a->ranges[$aIdx = 0]; $aEnd = $a->ranges[1]; } + if ($bHasMore) { $bStart = $b->ranges[$bIdx = 0]; $bEnd = $b->ranges[1]; } while ($aHasMore || $bHasMore) { if (!$bHasMore || ($aHasMore && $aEnd < $bStart)) { - $removedRanges[] = [$aStart, $aEnd]; + $removedRanges[] = $aStart; $removedRanges[] = $aEnd; $removedCount += $aEnd - $aStart + 1; - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $a->ranges[$aIdx += 2]; $aEnd = $a->ranges[$aIdx + 1]; } } elseif (!$aHasMore || $bEnd < $aStart) { - $addedRanges[] = [$bStart, $bEnd]; + $addedRanges[] = $bStart; $addedRanges[] = $bEnd; $addedCount += $bEnd - $bStart + 1; - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $b->ranges[$bIdx += 2]; $bEnd = $b->ranges[$bIdx + 1]; } } else { if ($aStart < $bStart) { - $removedRanges[] = [$aStart, $bStart - 1]; + $removedRanges[] = $aStart; $removedRanges[] = $bStart - 1; $removedCount += $bStart - $aStart; + $uStart = $bStart; } elseif ($bStart < $aStart) { - $addedRanges[] = [$bStart, $aStart - 1]; + $addedRanges[] = $bStart; $addedRanges[] = $aStart - 1; $addedCount += $aStart - $bStart; + $uStart = $aStart; + } else { + $uStart = $aStart; } - $uStart = max($aStart, $bStart); - $uEnd = min($aEnd, $bEnd); - $unchangedRanges[] = [$uStart, $uEnd]; + $uEnd = $aEnd < $bEnd ? $aEnd : $bEnd; + $unchangedRanges[] = $uStart; $unchangedRanges[] = $uEnd; $unchangedCount += $uEnd - $uStart + 1; $aConsumed = false; @@ -360,10 +364,10 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array else { $aConsumed = true; $bConsumed = true; } if ($aConsumed) { - if ($aHasMore = $aIdx < $ac) { [$aStart, $aEnd] = $a->ranges[++$aIdx]; } + if ($aHasMore = $aIdx < $ac) { $aStart = $a->ranges[$aIdx += 2]; $aEnd = $a->ranges[$aIdx + 1]; } } if ($bConsumed) { - if ($bHasMore = $bIdx < $bc) { [$bStart, $bEnd] = $b->ranges[++$bIdx]; } + if ($bHasMore = $bIdx < $bc) { $bStart = $b->ranges[$bIdx += 2]; $bEnd = $b->ranges[$bIdx + 1]; } } } } @@ -380,9 +384,9 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array */ public function getIterator(): \Traversable { - foreach ($this->ranges as [$start, $end]) { - for ($i = $start; $i <= $end; $i++) { - yield $i; + for ($i = 0, $c = count($this->ranges); $i < $c; $i += 2) { + for ($n = $this->ranges[$i]; $n <= $this->ranges[$i + 1]; $n++) { + yield $n; } } } @@ -407,14 +411,14 @@ public static function fromString(string $s): self } $start = (int)$m[1]; $end = isset($m[2]) ? (int)$m[2] : $start; - if ($start <= $end) [$rs, $re] = [$start, $end]; - else [$rs, $re] = [$end, $start]; - if ($c === 0 || $set->ranges[$c - 1][1] < $rs - 1) { - $set->ranges[] = [$rs, $re]; - $set->count += $re - $rs + 1; - $c++; + if ($start > $end) [$start, $end] = [$end, $start]; + if ($c === 0 || $set->ranges[$c - 1] < $start - 1) { + $set->ranges[] = $start; + $set->ranges[] = $end; + $set->count += $end - $start + 1; + $c += 2; } else { - $set->addRange($rs, $re, $c - 1); + $set->addRange($start, $end, $c - 2); $c = count($set->ranges); } } @@ -428,7 +432,7 @@ public static function fromString(string $s): self public function contains(int $n): bool { $i = $this->lowerBound($n); - return $i < count($this->ranges) && $this->ranges[$i][0] <= $n; + return $i < count($this->ranges) && $this->ranges[$i] <= $n; } /** @@ -445,9 +449,12 @@ public function count(): int */ public function __toString(): string { - return implode(',', array_map( - fn($r) => $r[0] === $r[1] ? (string)$r[0] : "{$r[0]}:{$r[1]}", - $this->ranges - )); + $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..5068611a --- /dev/null +++ b/lib/Horde/ActiveSync/benchmark.php @@ -0,0 +1,446 @@ + $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 + )); +} + +// --------------------------------------------------------------------------- +// Standard test data +// --------------------------------------------------------------------------- + +$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)); // all odd gaps + +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('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); +}); + + +$rangesA = makeRanges(500, 100, 1_000_000); +$rangesB = makeRanges(500, 100, 1_000_000); + +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. diff() variants +// --------------------------------------------------------------------------- + +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 = makeRanges(500, 10, 50_000); +// Force disjoint by offsetting D +$rangesD = array_map(fn($r) => [$r[0] + 60_000, $r[1] + 60_000], $rangesD); + +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); +}); + +// --------------------------------------------------------------------------- +// 12. fromString() +// --------------------------------------------------------------------------- + +section('fromString() parsing'); + +$uidString = makeUidString($ranges); +$uidStringLarge = makeUidString($rangesLarge); + + +$uidStringUnsorted = makeUidString(array_reverse($ranges)); + +benchmark('IntRangeSet::fromString() ~500 ranges', fn() => null, function() use ($uidString) { + IntRangeSet::fromString($uidString); +}); + +benchmark('IntRangeSet::fromString() ~500 ranges unsorted', fn() => null, function() use ($uidStringUnsorted) { + IntRangeSet::fromString($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); +}); + +// --------------------------------------------------------------------------- +// 13. 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 = IntRangeSet::fromString($prevString); + $curr = IntRangeSet::fromString($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..16915848 --- /dev/null +++ b/lib/Horde/ActiveSync/tests.php @@ -0,0 +1,591 @@ +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'); + +// addRange reversed +$s = new IntRangeSet(); +$s->addRange(10, 5); +check('addRange reversed', (string)$s, '5:10'); + +// removeRange reversed +$s = new IntRangeSet(); +$s->addRange(1, 20); +$s->removeRange(15, 5); +check('removeRange reversed', (string)$s, '1:4,16:20'); + +// fromString reversed range is accepted and normalized +$s = IntRangeSet::fromString('10:5'); +check('fromString reversed accepted', (string)$s, '5:10'); + +// fromString invalid input throws +try { + IntRangeSet::fromString('abc'); + check('fromString invalid throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('fromString 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'); + +// diff() partial remainder after B exhausted mid-overlap +$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', ''); + +// diff() single values disjoint +checkDiff('diff single values disjoint', + IntRangeSet::diff(IntRangeSet::fromString('1,3,5'), IntRangeSet::fromString('2,4,6')), + '1,3,5', '', '2,4,6'); + +// subtract self +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->subtract($a); +check('subtract self', (string)$a, ''); + +// count after subtract +$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=== fromString() / __toString() ===\n"; + +$s = IntRangeSet::fromString(''); +check('fromString empty', (string)$s, ''); + +$s = IntRangeSet::fromString('7'); +check('fromString single value', (string)$s, '7'); + +$s = IntRangeSet::fromString('1:5'); +check('fromString single range', (string)$s, '1:5'); + +$s = IntRangeSet::fromString('1:5,7,10:15'); +check('fromString mixed', (string)$s, '1:5,7,10:15'); + +$original = '1:5,7,10:15'; +$s = IntRangeSet::fromString($original); +check('round-trip simple', (string)$s, $original); + +$s = IntRangeSet::fromString('1:5,3:8'); +check('fromString merges overlapping', (string)$s, '1:8'); + +$s = IntRangeSet::fromString('1:5,6:10'); +check('fromString merges adjacent', (string)$s, '1:10'); + +// fromString colon only throws +try { + IntRangeSet::fromString(':'); + check('fromString colon only throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('fromString colon only throws', 'exception', 'exception'); +} + +// fromString empty part throws +try { + IntRangeSet::fromString('1,,3'); + check('fromString empty part throws', 'no exception', 'exception'); +} catch (\InvalidArgumentException $e) { + check('fromString empty part throws', 'exception', 'exception'); +} + +// ------------------------------------------------------------------------- +echo "\n=== union() ===\n"; + +// Union with empty set +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->union(new IntRangeSet()); +check('union with empty', (string)$a, '1:10'); + +// Union from empty set +$a = new IntRangeSet(); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->union($b); +check('union from empty', (string)$a, '1:10'); + +// Union non-overlapping +$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'); + +// Union overlapping +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(5, 15); +$a->union($b); +check('union overlapping', (string)$a, '1:15'); + +// Union adjacent +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(6, 10); +$a->union($b); +check('union adjacent', (string)$a, '1:10'); + +// Union with self +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->union($a); +check('union with self', (string)$a, '1:10'); + +// union() cross-side merge bug check +$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"; + +// Intersect with empty +$a = new IntRangeSet(); +$a->addRange(1, 10); +$a->intersect(new IntRangeSet()); +check('intersect with empty', (string)$a, ''); + +// Intersect from empty +$a = new IntRangeSet(); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->intersect($b); +check('intersect from empty', (string)$a, ''); + +// Intersect identical +$a = new IntRangeSet(); +$a->addRange(1, 10); +$b = new IntRangeSet(); +$b->addRange(1, 10); +$a->intersect($b); +check('intersect identical', (string)$a, '1:10'); + +// Intersect non-overlapping +$a = new IntRangeSet(); +$a->addRange(1, 5); +$b = new IntRangeSet(); +$b->addRange(10, 15); +$a->intersect($b); +check('intersect non-overlapping', (string)$a, ''); + +// Intersect partial overlap +$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 contains B +$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'); + +// Multiple ranges +$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"; + +function checkDiff(string $label, array $diff, string $removed, string $unchanged, string $added): void { + global $pass, $fail; + [$r, $u, $a] = $diff; + $ok = (string)$r === $removed + && (string)$u === $unchanged + && (string)$a === $added; + if ($ok) { + echo " PASS $label\n"; + $pass++; + } else { + echo " FAIL $label\n"; + if ((string)$r !== $removed) echo " removed expected: $removed got: $r\n"; + if ((string)$u !== $unchanged) echo " unchanged expected: $unchanged got: $u\n"; + if ((string)$a !== $added) echo " added expected: $added got: $a\n"; + $fail++; + } +} + +// Both empty +checkDiff('both empty', + IntRangeSet::diff(new IntRangeSet(), new IntRangeSet()), + '', '', ''); + +// A empty, B non-empty +checkDiff('a empty', + IntRangeSet::diff(new IntRangeSet(), IntRangeSet::fromString('1:5')), + '', '', '1:5'); + +// B empty, A non-empty +checkDiff('b empty', + IntRangeSet::diff(IntRangeSet::fromString('1:5'), new IntRangeSet()), + '1:5', '', ''); + +// Identical sets +checkDiff('identical sets', + IntRangeSet::diff(IntRangeSet::fromString('1:5,10:15'), IntRangeSet::fromString('1:5,10:15')), + '', '1:5,10:15', ''); + +// Completely disjoint — A entirely before B +checkDiff('disjoint a before b', + IntRangeSet::diff(IntRangeSet::fromString('1:5'), IntRangeSet::fromString('10:15')), + '1:5', '', '10:15'); + +// Completely disjoint — B entirely before A +checkDiff('disjoint b before a', + IntRangeSet::diff(IntRangeSet::fromString('10:15'), IntRangeSet::fromString('1:5')), + '10:15', '', '1:5'); + +// Partial overlap — A extends left +checkDiff('partial overlap a extends left', + IntRangeSet::diff(IntRangeSet::fromString('1:10'), IntRangeSet::fromString('5:15')), + '1:4', '5:10', '11:15'); + +// Partial overlap — B extends left +checkDiff('partial overlap b extends left', + IntRangeSet::diff(IntRangeSet::fromString('5:15'), IntRangeSet::fromString('1:10')), + '11:15', '5:10', '1:4'); + +// A contains B +checkDiff('a contains b', + IntRangeSet::diff(IntRangeSet::fromString('1:20'), IntRangeSet::fromString('5:10')), + '1:4,11:20', '5:10', ''); + +// B contains A +checkDiff('b contains a', + IntRangeSet::diff(IntRangeSet::fromString('5:10'), IntRangeSet::fromString('1:20')), + '', '5:10', '1:4,11:20'); + +// Multiple ranges each side, interleaved +checkDiff('interleaved ranges', + IntRangeSet::diff(IntRangeSet::fromString('1:5,11:15,21:25'), IntRangeSet::fromString('3:13,23:30')), + '1:2,14:15,21:22', '3:5,11:13,23:25', '6:10,26:30'); + +// Single values +checkDiff('single values overlap', + IntRangeSet::diff(IntRangeSet::fromString('1,2,3'), IntRangeSet::fromString('2,3,4')), + '1', '2:3', '4'); + +// ------------------------------------------------------------------------- +echo "\n=== getIterator() ===\n"; + +// Empty set +$result = []; +foreach (new IntRangeSet() as $v) $result[] = $v; +check('iterate empty', implode(',', $result), ''); + +// Single value +$result = []; +foreach (IntRangeSet::fromString('5') as $v) $result[] = $v; +check('iterate single value', implode(',', $result), '5'); + +// Single range +$result = []; +foreach (IntRangeSet::fromString('1:5') as $v) $result[] = $v; +check('iterate single range', implode(',', $result), '1,2,3,4,5'); + +// Multiple ranges +$result = []; +foreach (IntRangeSet::fromString('1:3,7,10:12') as $v) $result[] = $v; +check('iterate multiple ranges', implode(',', $result), '1,2,3,7,10,11,12'); + +// Ranges are always stored in ascending order +$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'); + +// Iterate over negative numbers +$s = new IntRangeSet(); +$s->addRange(-3, -1); +$result = []; +foreach ($s as $v) $result[] = $v; +check('iterate negative numbers', implode(',', $result), '-3,-2,-1'); + +// diff() partial remainder carried across multiple iterations +checkDiff('diff partial remainder carried across iterations', + IntRangeSet::diff(IntRangeSet::fromString('1:30'), IntRangeSet::fromString('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(); +$s->addRange(1, 5)->addRange(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"; From fc49ffe3e9dd62e72717925ec0f2fa2423e15a53 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Wed, 8 Apr 2026 16:29:56 -0400 Subject: [PATCH 3/3] O(log n) binary search with lowerBound cache for O(1) sequential/repeated lookups Fast-append path in addRange for sorted input O(m+n) two-pointer sweeps for union, subtract, intersect, diff String initialization via constructor --- lib/Horde/ActiveSync/IntRangeSet.php | 178 +++++++++++++++------------ lib/Horde/ActiveSync/benchmark.php | 65 +++++----- lib/Horde/ActiveSync/tests.php | 177 +++++++++++--------------- 3 files changed, 200 insertions(+), 220 deletions(-) diff --git a/lib/Horde/ActiveSync/IntRangeSet.php b/lib/Horde/ActiveSync/IntRangeSet.php index 68da813c..7045a36e 100644 --- a/lib/Horde/ActiveSync/IntRangeSet.php +++ b/lib/Horde/ActiveSync/IntRangeSet.php @@ -4,14 +4,15 @@ error_reporting(E_ALL); /** - * IntRangeSet v0.5 + * IntRangeSet v0.6 * * Maintains an ordered, compact set of integer ranges. * Ranges are stored as a flat array [start1, end1, start2, end2, ...] * in ascending order. * * String representation uses IMAP-style syntax: "1:5,7,10:15" - * Note: fromString() only supports non-negative integers. + * Note: fromString() only supports non-negative integers and does not + * accept whitespace in the input string. * * @author: Dmitry Petrov */ @@ -19,41 +20,88 @@ 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; + private array $ranges = []; /** @var int Cached total count of integers in the set */ - private int $count; + private int $count = 0; - public function __construct(array $ranges = [], 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. + * 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->ranges[$lo + 1] >= $n) return $lo; + if ($hi < $lo || $this->ranges[$hi + 1] < $n) { + return $hi + 2; + } - do { - // Round down to even index - $mid = (($lo + $hi) >> 1) & ~1; - $midEnd = $this->ranges[$mid + 1]; - if ($midEnd < $n) $lo = $mid + 2; - elseif ($midEnd === $n) return $mid; - else $hi = $mid - 2; - } while ($lo <= $hi); + 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 $lo; + return $this->lastLowerBound = $lo; } /** @@ -69,14 +117,24 @@ public function add(int $n): self * merging adjacent or overlapping ranges. * If start > end the values are swapped. * - * @param int $lo Internal hint for lowerBound start index; do not use externally. + * 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, int $lo = 0): self + public function addRange(int $start, int $end): self { if ($start > $end) [$start, $end] = [$end, $start]; - $c = count($this->ranges); - $first = $this->lowerBound($start, $lo); + $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 @@ -88,7 +146,7 @@ public function addRange(int $start, int $end, int $lo = 0): self } } - // Check left neighbor for adjacency ($first - 1 is the end value of previous range) + // 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; } @@ -157,6 +215,7 @@ public function removeRange(int $start, int $end): self $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 { @@ -186,8 +245,8 @@ public function union(IntRangeSet $other): self $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; $aIdx = $bIdx = -2; - if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } - if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } + 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)) { @@ -217,9 +276,7 @@ public function union(IntRangeSet $other): self $count += $curEnd - $curStart + 1; } - $this->ranges = $ranges; - $this->count = $count; - return $this; + return $this->init($ranges, $count); } /** @@ -235,8 +292,8 @@ public function subtract(IntRangeSet $other): self $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; $aIdx = $bIdx = -2; - if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } - if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } + 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) { @@ -259,9 +316,7 @@ public function subtract(IntRangeSet $other): self } } - $this->ranges = $ranges; - $this->count = $count; - return $this; + return $this->init($ranges, $count); } /** @@ -277,8 +332,8 @@ public function intersect(IntRangeSet $other): self $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; $aIdx = $bIdx = -2; - if ($aHasMore) { $aStart = $this->ranges[$aIdx = 0]; $aEnd = $this->ranges[1]; } - if ($bHasMore) { $bStart = $other->ranges[$bIdx = 0]; $bEnd = $other->ranges[1]; } + 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) { @@ -302,9 +357,7 @@ public function intersect(IntRangeSet $other): self } } - $this->ranges = $ranges; - $this->count = $count; - return $this; + return $this->init($ranges, $count); } /** @@ -328,8 +381,8 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array $aHasMore = $ac >= 0; $bHasMore = $bc >= 0; $aIdx = $bIdx = -2; - if ($aHasMore) { $aStart = $a->ranges[$aIdx = 0]; $aEnd = $a->ranges[1]; } - if ($bHasMore) { $bStart = $b->ranges[$bIdx = 0]; $bEnd = $b->ranges[1]; } + 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)) { @@ -357,11 +410,11 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array $unchangedRanges[] = $uStart; $unchangedRanges[] = $uEnd; $unchangedCount += $uEnd - $uStart + 1; - $aConsumed = false; - $bConsumed = false; + // 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 = true; $bConsumed = true; } + else { $aConsumed = $bConsumed = true; } if ($aConsumed) { if ($aHasMore = $aIdx < $ac) { $aStart = $a->ranges[$aIdx += 2]; $aEnd = $a->ranges[$aIdx + 1]; } @@ -373,9 +426,9 @@ public static function diff(IntRangeSet $a, IntRangeSet $b): array } return [ - new self($removedRanges, $removedCount), - new self($unchangedRanges, $unchangedCount), - new self($addedRanges, $addedCount), + self::create($removedRanges, $removedCount), + self::create($unchangedRanges, $unchangedCount), + self::create($addedRanges, $addedCount), ]; } @@ -391,41 +444,6 @@ public function getIterator(): \Traversable } } - /** - * Parse an IMAP-style UID set string, e.g. "1:5,7,10:15" - * and return a new IntRangeSet. - * Only non-negative integers are supported in string format. - * Input may be unsorted or contain overlapping/adjacent ranges. - * - * @throws \InvalidArgumentException on malformed input - */ - public static function fromString(string $s): self - { - $set = new self(); - if ($s === '') return $set; - - $c = 0; - 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; - if ($start > $end) [$start, $end] = [$end, $start]; - if ($c === 0 || $set->ranges[$c - 1] < $start - 1) { - $set->ranges[] = $start; - $set->ranges[] = $end; - $set->count += $end - $start + 1; - $c += 2; - } else { - $set->addRange($start, $end, $c - 2); - $c = count($set->ranges); - } - } - - return $set; - } - /** * Check whether a value is present in the set. */ diff --git a/lib/Horde/ActiveSync/benchmark.php b/lib/Horde/ActiveSync/benchmark.php index 5068611a..2953496d 100644 --- a/lib/Horde/ActiveSync/benchmark.php +++ b/lib/Horde/ActiveSync/benchmark.php @@ -1,7 +1,7 @@ [$i * 2, $i * 2], range(1, 10_000)); // all odd gaps +$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); @@ -291,6 +291,22 @@ function makeUidString(array $ranges): string // 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) { @@ -316,22 +332,8 @@ function makeUidString(array $ranges): string array_intersect($a, $b); }); - -$rangesA = makeRanges(500, 100, 1_000_000); -$rangesB = makeRanges(500, 100, 1_000_000); - -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. diff() variants +// 12. diff() // --------------------------------------------------------------------------- section('diff() — identical sets'); @@ -344,9 +346,7 @@ function makeUidString(array $ranges): string section('diff() — completely disjoint sets'); $rangesC = makeRanges(500, 10, 50_000); -$rangesD = makeRanges(500, 10, 50_000); -// Force disjoint by offsetting D -$rangesD = array_map(fn($r) => [$r[0] + 60_000, $r[1] + 60_000], $rangesD); +$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; @@ -375,23 +375,20 @@ function makeUidString(array $ranges): string }); // --------------------------------------------------------------------------- -// 12. fromString() +// 13. constructor string parsing // --------------------------------------------------------------------------- -section('fromString() parsing'); - -$uidString = makeUidString($ranges); -$uidStringLarge = makeUidString($rangesLarge); - +section('constructor string parsing'); +$uidString = makeUidString($ranges); $uidStringUnsorted = makeUidString(array_reverse($ranges)); -benchmark('IntRangeSet::fromString() ~500 ranges', fn() => null, function() use ($uidString) { - IntRangeSet::fromString($uidString); +benchmark('new IntRangeSet() ~500 ranges sorted', fn() => null, function() use ($uidString) { + new IntRangeSet($uidString); }); -benchmark('IntRangeSet::fromString() ~500 ranges unsorted', fn() => null, function() use ($uidStringUnsorted) { - IntRangeSet::fromString($uidStringUnsorted); +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) { @@ -415,7 +412,7 @@ function makeUidString(array $ranges): string }); // --------------------------------------------------------------------------- -// 13. Simulated IMAP sync +// 14. Simulated IMAP sync // --------------------------------------------------------------------------- section('Simulated IMAP sync (parse, diff, add, remove)'); @@ -426,8 +423,8 @@ function makeUidString(array $ranges): string $newString = makeUidString($newRanges); benchmark('IntRangeSet IMAP sync', fn() => null, function() use ($prevString, $newString) { - $prev = IntRangeSet::fromString($prevString); - $curr = IntRangeSet::fromString($newString); + $prev = new IntRangeSet($prevString); + $curr = new IntRangeSet($newString); [$removed, $unchanged, $added] = IntRangeSet::diff($prev, $curr); $result = clone $prev; $result->subtract($removed)->union($added); diff --git a/lib/Horde/ActiveSync/tests.php b/lib/Horde/ActiveSync/tests.php index 16915848..c489a619 100644 --- a/lib/Horde/ActiveSync/tests.php +++ b/lib/Horde/ActiveSync/tests.php @@ -1,5 +1,5 @@ addRange(25, 30); check('addRange after all', (string)$s, '1:3,10:20,25:30'); -// addRange reversed $s = new IntRangeSet(); $s->addRange(10, 5); check('addRange reversed', (string)$s, '5:10'); -// removeRange reversed $s = new IntRangeSet(); $s->addRange(1, 20); $s->removeRange(15, 5); check('removeRange reversed', (string)$s, '1:4,16:20'); -// fromString reversed range is accepted and normalized -$s = IntRangeSet::fromString('10:5'); -check('fromString reversed accepted', (string)$s, '5:10'); +// constructor accepts reversed range +$s = new IntRangeSet('10:5'); +check('constructor reversed accepted', (string)$s, '5:10'); -// fromString invalid input throws +// constructor invalid input throws try { - IntRangeSet::fromString('abc'); - check('fromString invalid throws', 'no exception', 'exception'); + new IntRangeSet('abc'); + check('constructor invalid throws', 'no exception', 'exception'); } catch (\InvalidArgumentException $e) { - check('fromString invalid throws', 'exception', 'exception'); + check('constructor invalid throws', 'exception', 'exception'); } // ------------------------------------------------------------------------- @@ -193,7 +207,11 @@ function checkBool(string $label, bool $got, bool $expected): void { $s->removeRange(5, 5); check('removeRange single value', (string)$s, '1:4,6:10'); -// diff() partial remainder after B exhausted mid-overlap +$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(); @@ -202,18 +220,15 @@ function checkBool(string $label, bool $got, bool $expected): void { IntRangeSet::diff($a, $b), '11:20', '1:10', ''); -// diff() single values disjoint checkDiff('diff single values disjoint', - IntRangeSet::diff(IntRangeSet::fromString('1,3,5'), IntRangeSet::fromString('2,4,6')), + IntRangeSet::diff(new IntRangeSet('1,3,5'), new IntRangeSet('2,4,6')), '1,3,5', '', '2,4,6'); -// subtract self $a = new IntRangeSet(); $a->addRange(1, 10); $a->subtract($a); check('subtract self', (string)$a, ''); -// count after subtract $a = new IntRangeSet(); $a->addRange(1, 10); $b = new IntRangeSet(); @@ -222,63 +237,58 @@ function checkBool(string $label, bool $got, bool $expected): void { checkInt('count after subtract', $a->count(), 5); // ------------------------------------------------------------------------- -echo "\n=== fromString() / __toString() ===\n"; +echo "\n=== constructor / __toString() ===\n"; -$s = IntRangeSet::fromString(''); -check('fromString empty', (string)$s, ''); +$s = new IntRangeSet(''); +check('constructor empty', (string)$s, ''); -$s = IntRangeSet::fromString('7'); -check('fromString single value', (string)$s, '7'); +$s = new IntRangeSet('7'); +check('constructor single value', (string)$s, '7'); -$s = IntRangeSet::fromString('1:5'); -check('fromString single range', (string)$s, '1:5'); +$s = new IntRangeSet('1:5'); +check('constructor single range', (string)$s, '1:5'); -$s = IntRangeSet::fromString('1:5,7,10:15'); -check('fromString mixed', (string)$s, '1:5,7,10:15'); +$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 = IntRangeSet::fromString($original); +$s = new IntRangeSet($original); check('round-trip simple', (string)$s, $original); -$s = IntRangeSet::fromString('1:5,3:8'); -check('fromString merges overlapping', (string)$s, '1:8'); +$s = new IntRangeSet('1:5,3:8'); +check('constructor merges overlapping', (string)$s, '1:8'); -$s = IntRangeSet::fromString('1:5,6:10'); -check('fromString merges adjacent', (string)$s, '1:10'); +$s = new IntRangeSet('1:5,6:10'); +check('constructor merges adjacent', (string)$s, '1:10'); -// fromString colon only throws try { - IntRangeSet::fromString(':'); - check('fromString colon only throws', 'no exception', 'exception'); + new IntRangeSet(':'); + check('constructor colon only throws', 'no exception', 'exception'); } catch (\InvalidArgumentException $e) { - check('fromString colon only throws', 'exception', 'exception'); + check('constructor colon only throws', 'exception', 'exception'); } -// fromString empty part throws try { - IntRangeSet::fromString('1,,3'); - check('fromString empty part throws', 'no exception', 'exception'); + new IntRangeSet('1,,3'); + check('constructor empty part throws', 'no exception', 'exception'); } catch (\InvalidArgumentException $e) { - check('fromString empty part throws', 'exception', 'exception'); + check('constructor empty part throws', 'exception', 'exception'); } // ------------------------------------------------------------------------- echo "\n=== union() ===\n"; -// Union with empty set $a = new IntRangeSet(); $a->addRange(1, 10); $a->union(new IntRangeSet()); check('union with empty', (string)$a, '1:10'); -// Union from empty set $a = new IntRangeSet(); $b = new IntRangeSet(); $b->addRange(1, 10); $a->union($b); check('union from empty', (string)$a, '1:10'); -// Union non-overlapping $a = new IntRangeSet(); $a->addRange(1, 5); $b = new IntRangeSet(); @@ -286,7 +296,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->union($b); check('union non-overlapping', (string)$a, '1:5,10:15'); -// Union overlapping $a = new IntRangeSet(); $a->addRange(1, 10); $b = new IntRangeSet(); @@ -294,7 +303,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->union($b); check('union overlapping', (string)$a, '1:15'); -// Union adjacent $a = new IntRangeSet(); $a->addRange(1, 5); $b = new IntRangeSet(); @@ -302,13 +310,11 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->union($b); check('union adjacent', (string)$a, '1:10'); -// Union with self $a = new IntRangeSet(); $a->addRange(1, 10); $a->union($a); check('union with self', (string)$a, '1:10'); -// union() cross-side merge bug check $a = new IntRangeSet(); $a->addRange(1, 5)->addRange(8, 12); $b = new IntRangeSet(); @@ -319,20 +325,17 @@ function checkBool(string $label, bool $got, bool $expected): void { // ------------------------------------------------------------------------- echo "\n=== intersect() ===\n"; -// Intersect with empty $a = new IntRangeSet(); $a->addRange(1, 10); $a->intersect(new IntRangeSet()); check('intersect with empty', (string)$a, ''); -// Intersect from empty $a = new IntRangeSet(); $b = new IntRangeSet(); $b->addRange(1, 10); $a->intersect($b); check('intersect from empty', (string)$a, ''); -// Intersect identical $a = new IntRangeSet(); $a->addRange(1, 10); $b = new IntRangeSet(); @@ -340,7 +343,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->intersect($b); check('intersect identical', (string)$a, '1:10'); -// Intersect non-overlapping $a = new IntRangeSet(); $a->addRange(1, 5); $b = new IntRangeSet(); @@ -348,7 +350,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->intersect($b); check('intersect non-overlapping', (string)$a, ''); -// Intersect partial overlap $a = new IntRangeSet(); $a->addRange(1, 10); $b = new IntRangeSet(); @@ -356,7 +357,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->intersect($b); check('intersect partial overlap', (string)$a, '5:10'); -// A contains B $a = new IntRangeSet(); $a->addRange(1, 20); $b = new IntRangeSet(); @@ -364,7 +364,6 @@ function checkBool(string $label, bool $got, bool $expected): void { $a->intersect($b); check('intersect a contains b', (string)$a, '5:10'); -// Multiple ranges $a = new IntRangeSet(); $a->addRange(1, 5)->addRange(10, 15)->addRange(20, 25); $b = new IntRangeSet(); @@ -438,131 +437,97 @@ function checkBool(string $label, bool $got, bool $expected): void { // ------------------------------------------------------------------------- echo "\n=== diff() ===\n"; -function checkDiff(string $label, array $diff, string $removed, string $unchanged, string $added): void { - global $pass, $fail; - [$r, $u, $a] = $diff; - $ok = (string)$r === $removed - && (string)$u === $unchanged - && (string)$a === $added; - if ($ok) { - echo " PASS $label\n"; - $pass++; - } else { - echo " FAIL $label\n"; - if ((string)$r !== $removed) echo " removed expected: $removed got: $r\n"; - if ((string)$u !== $unchanged) echo " unchanged expected: $unchanged got: $u\n"; - if ((string)$a !== $added) echo " added expected: $added got: $a\n"; - $fail++; - } -} - -// Both empty checkDiff('both empty', IntRangeSet::diff(new IntRangeSet(), new IntRangeSet()), '', '', ''); -// A empty, B non-empty checkDiff('a empty', - IntRangeSet::diff(new IntRangeSet(), IntRangeSet::fromString('1:5')), + IntRangeSet::diff(new IntRangeSet(), new IntRangeSet('1:5')), '', '', '1:5'); -// B empty, A non-empty checkDiff('b empty', - IntRangeSet::diff(IntRangeSet::fromString('1:5'), new IntRangeSet()), + IntRangeSet::diff(new IntRangeSet('1:5'), new IntRangeSet()), '1:5', '', ''); -// Identical sets checkDiff('identical sets', - IntRangeSet::diff(IntRangeSet::fromString('1:5,10:15'), IntRangeSet::fromString('1:5,10:15')), + IntRangeSet::diff(new IntRangeSet('1:5,10:15'), new IntRangeSet('1:5,10:15')), '', '1:5,10:15', ''); -// Completely disjoint — A entirely before B checkDiff('disjoint a before b', - IntRangeSet::diff(IntRangeSet::fromString('1:5'), IntRangeSet::fromString('10:15')), + IntRangeSet::diff(new IntRangeSet('1:5'), new IntRangeSet('10:15')), '1:5', '', '10:15'); -// Completely disjoint — B entirely before A checkDiff('disjoint b before a', - IntRangeSet::diff(IntRangeSet::fromString('10:15'), IntRangeSet::fromString('1:5')), + IntRangeSet::diff(new IntRangeSet('10:15'), new IntRangeSet('1:5')), '10:15', '', '1:5'); -// Partial overlap — A extends left checkDiff('partial overlap a extends left', - IntRangeSet::diff(IntRangeSet::fromString('1:10'), IntRangeSet::fromString('5:15')), + IntRangeSet::diff(new IntRangeSet('1:10'), new IntRangeSet('5:15')), '1:4', '5:10', '11:15'); -// Partial overlap — B extends left checkDiff('partial overlap b extends left', - IntRangeSet::diff(IntRangeSet::fromString('5:15'), IntRangeSet::fromString('1:10')), + IntRangeSet::diff(new IntRangeSet('5:15'), new IntRangeSet('1:10')), '11:15', '5:10', '1:4'); -// A contains B checkDiff('a contains b', - IntRangeSet::diff(IntRangeSet::fromString('1:20'), IntRangeSet::fromString('5:10')), + IntRangeSet::diff(new IntRangeSet('1:20'), new IntRangeSet('5:10')), '1:4,11:20', '5:10', ''); -// B contains A checkDiff('b contains a', - IntRangeSet::diff(IntRangeSet::fromString('5:10'), IntRangeSet::fromString('1:20')), + IntRangeSet::diff(new IntRangeSet('5:10'), new IntRangeSet('1:20')), '', '5:10', '1:4,11:20'); -// Multiple ranges each side, interleaved checkDiff('interleaved ranges', - IntRangeSet::diff(IntRangeSet::fromString('1:5,11:15,21:25'), IntRangeSet::fromString('3:13,23:30')), + 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'); -// Single values checkDiff('single values overlap', - IntRangeSet::diff(IntRangeSet::fromString('1,2,3'), IntRangeSet::fromString('2,3,4')), + 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"; -// Empty set $result = []; foreach (new IntRangeSet() as $v) $result[] = $v; check('iterate empty', implode(',', $result), ''); -// Single value $result = []; -foreach (IntRangeSet::fromString('5') as $v) $result[] = $v; +foreach (new IntRangeSet('5') as $v) $result[] = $v; check('iterate single value', implode(',', $result), '5'); -// Single range $result = []; -foreach (IntRangeSet::fromString('1:5') as $v) $result[] = $v; +foreach (new IntRangeSet('1:5') as $v) $result[] = $v; check('iterate single range', implode(',', $result), '1,2,3,4,5'); -// Multiple ranges $result = []; -foreach (IntRangeSet::fromString('1:3,7,10:12') as $v) $result[] = $v; +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'); -// Ranges are always stored in ascending order $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'); -// Iterate over negative numbers $s = new IntRangeSet(); $s->addRange(-3, -1); $result = []; foreach ($s as $v) $result[] = $v; check('iterate negative numbers', implode(',', $result), '-3,-2,-1'); -// diff() partial remainder carried across multiple iterations checkDiff('diff partial remainder carried across iterations', - IntRangeSet::diff(IntRangeSet::fromString('1:30'), IntRangeSet::fromString('5:10,15:20,25:30')), + 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(); -$s->addRange(1, 5)->addRange(10, 15); +$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);