Issues (1)

src/RangeSet.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Remorhaz\IntRangeSets;
6
7
use Iterator;
8
9
use function array_map;
10
use function array_search;
11
use function count;
12
use function usort;
13
14
/**
15
 * Immutable set of integer ranges.
16
 *
17
 * @psalm-immutable
18
 */
19
final class RangeSet implements RangeSetInterface
20
{
21
22
    /**
23
     * @var RangeInterface[]
24
     * @psalm-var list<RangeInterface>
25
     */
26
    private $ranges;
27
28
    /**
29
     * Creates set of ranges that contain all values from given ranges.
30
     *
31
     * @param RangeInterface ...$ranges
32
     * @return RangeSetInterface
33
     * @psalm-pure
34
     */
35 4
    public static function create(RangeInterface ...$ranges): RangeSetInterface
36
    {
37 4
        $rangeSet = new self();
38
39 4
        return $rangeSet->withRanges(...$ranges);
40
    }
41
42
    /**
43
     * Provided ranges must be sorted, must not overlap and must not follow each other without gaps.
44
     * Warning: no checks are made! Use {@see RangeSet::createUnion()} to create set from arbitrary list of ranges.
45
     *
46
     * @param RangeInterface ...$ranges
47
     * @return RangeSetInterface
48
     * @psalm-pure
49
     */
50 62
    public static function createUnsafe(RangeInterface ...$ranges): RangeSetInterface
51
    {
52 62
        return new self(...$ranges);
53
    }
54
55
    /**
56
     * @param int[] ...$rangeDataList
57
     * @return RangeInterface[]
58
     * @psalm-pure
59
     */
60 60
    public static function importRanges(array ...$rangeDataList): array
61
    {
62
        /** @var RangeInterface[] $ranges */
63 60
        $ranges = array_map([self::class, 'importRange'], $rangeDataList);
64
65 60
        return $ranges;
66
    }
67
68
    /**
69
     * @param int[] $args
70
     * @return RangeInterface
71
     * @psalm-pure
72
     */
73 57
    private static function importRange(array $args): RangeInterface
74
    {
75 57
        return new Range(/** @scrutinizer ignore-type */ ...$args);
0 ignored issues
show
$args is expanded, but the parameter $start of Remorhaz\IntRangeSets\Range::__construct() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

75
        return new Range(/** @scrutinizer ignore-type */ /** @scrutinizer ignore-type */ ...$args);
Loading history...
76
    }
77
78 62
    private function __construct(RangeInterface ...$ranges)
79
    {
80 62
        $this->ranges = $ranges;
81 62
    }
82
83
    /**
84
     * {@inheritDoc}
85
     *
86
     * @param RangeSetInterface $rangeSet
87
     * @return RangeSetInterface
88
     */
89 3
    public function createUnion(RangeSetInterface $rangeSet): RangeSetInterface
90
    {
91 3
        return $this->mergeRanges(...$rangeSet->getRanges());
92
    }
93
94
    /**
95
     * {@inheritDoc}
96
     *
97
     * @param RangeInterface ...$ranges
98
     * @return RangeSetInterface
99
     */
100 24
    public function withRanges(RangeInterface ...$ranges): RangeSetInterface
101
    {
102 24
        return $this->mergeRanges(...$this->getSortedRanges(...$ranges));
103
    }
104
105
    /**
106
     * @param RangeInterface ...$ranges
107
     * @return RangeInterface[]
108
     * @psalm-return array<int, RangeInterface>
109
     */
110 24
    private function getSortedRanges(RangeInterface ...$ranges): array
111
    {
112
        /** @psalm-suppress ImpureFunctionCall See https://github.com/vimeo/psalm/issues/4224 */
113 24
        usort($ranges, [$this, 'compareRanges']);
114
115 24
        return $ranges;
116
    }
117
118
    /**
119
     * @param RangeInterface $firstRange
120
     * @param RangeInterface $secondRange
121
     * @return int
122
     */
123 3
    private function compareRanges(RangeInterface $firstRange, RangeInterface $secondRange): int
124
    {
125 3
        return $firstRange->getStart() <=> $secondRange->getStart();
126
    }
127
128
    /**
129
     * @param RangeInterface ...$ranges
130
     * @return RangeSetInterface
131
     */
132 27
    private function mergeRanges(RangeInterface ...$ranges): RangeSetInterface
133
    {
134 27
        $resultRanges = [];
135
        /** @var RangeInterface|null $rangeBuffer */
136 27
        $rangeBuffer = null;
137 27
        foreach ($this->createRangePicker($this->ranges, $ranges) as $pickedRange) {
138 24
            if (isset($rangeBuffer)) {
139 22
                if ($rangeBuffer->containsValue($pickedRange->getFinish())) {
140 6
                    continue;
141
                }
142 16
                if ($rangeBuffer->containsValue($pickedRange->getStart()) || $pickedRange->follows($rangeBuffer)) {
143 13
                    $rangeBuffer = $pickedRange->withStart($rangeBuffer->getStart());
144 13
                    continue;
145
                }
146 4
                $resultRanges[] = $rangeBuffer;
147
            }
148 24
            $rangeBuffer = $pickedRange;
149
        }
150 27
        if (isset($rangeBuffer)) {
151 24
            $resultRanges[] = $rangeBuffer;
152
        }
153
154 27
        return self::createUnsafe(...$resultRanges);
155
    }
156
157
    /**
158
     * @param RangeSetInterface $rangeSet
159
     * @return RangeSetInterface
160
     */
161 11
    public function createSymmetricDifference(RangeSetInterface $rangeSet): RangeSetInterface
162
    {
163 11
        $resultRanges = [];
164
        /** @var RangeInterface|null $rangeBuffer */
165 11
        $rangeBuffer = null;
166 11
        foreach ($this->createRangePicker($this->ranges, $rangeSet->getRanges()) as $pickedRange) {
167 11
            if (isset($rangeBuffer)) {
168 9
                if ($rangeBuffer->intersects($pickedRange)) {
169 6
                    $pickedRangeStart = $pickedRange->getStart();
170 6
                    if ($rangeBuffer->getStart() < $pickedRangeStart) {
171 6
                        $resultRanges[] = $rangeBuffer->withFinish($pickedRangeStart - 1);
172 6
                        $rangeBuffer = $rangeBuffer->withStart($pickedRangeStart);
173
                    }
174
175 6
                    $pickedRangeFinish = $pickedRange->getFinish();
176 6
                    $rangeBufferFinish = $rangeBuffer->getFinish();
177 6
                    if ($rangeBufferFinish < $pickedRangeFinish) {
178 2
                        $rangeBuffer = $pickedRange->withStart($rangeBufferFinish + 1);
179 4
                    } elseif ($pickedRangeFinish < $rangeBufferFinish) {
180 3
                        $rangeBuffer = $rangeBuffer->withStart($pickedRangeFinish + 1);
181
                    } else {
182 1
                        $rangeBuffer = null;
183
                    }
184 6
                    continue;
185
                }
186 3
                if ($pickedRange->follows($rangeBuffer)) {
187 1
                    $rangeBuffer = $rangeBuffer->withFinish($pickedRange->getFinish());
188 1
                    continue;
189
                }
190 2
                $resultRanges[] = $rangeBuffer;
191
            }
192 11
            $rangeBuffer = $pickedRange;
193
        }
194 11
        if (isset($rangeBuffer)) {
195 10
            $resultRanges[] = $rangeBuffer;
196
        }
197
198 11
        return self::createUnsafe(...$resultRanges);
199
    }
200
201
    /**
202
     * {@inheritDoc}
203
     *
204
     * @param RangeSetInterface $rangeSet
205
     * @return RangeSetInterface
206
     */
207 18
    public function createIntersection(RangeSetInterface $rangeSet): RangeSetInterface
208
    {
209 18
        $resultRanges = [];
210
        /** @var RangeInterface|null $rangeBuffer */
211 18
        $rangeBuffer = null;
212 18
        foreach ($this->createRangePicker($this->ranges, $rangeSet->getRanges()) as $pickedRange) {
213 18
            if (isset($rangeBuffer)) {
214 16
                if (!$rangeBuffer->intersects($pickedRange)) {
215 5
                    $rangeBuffer = $pickedRange;
216 5
                    continue;
217
                }
218 12
                $pickedRangeStart = $pickedRange->getStart();
219 12
                if ($rangeBuffer->getStart() < $pickedRangeStart) {
220 9
                    $rangeBuffer = $rangeBuffer->withStart($pickedRangeStart);
221
                }
222 12
                $pickedRangeFinish = $pickedRange->getFinish();
223 12
                if ($rangeBuffer->getFinish() > $pickedRangeFinish) {
224 5
                    $resultRanges[] = $rangeBuffer->withFinish($pickedRangeFinish);
225 5
                    $rangeBuffer = $rangeBuffer->withStart($pickedRangeFinish + 1);
226 5
                    continue;
227
                }
228 8
                $resultRanges[] = $rangeBuffer;
229
            }
230 18
            $rangeBuffer = $pickedRange;
231
        }
232
233 18
        return self::createUnsafe(...$resultRanges);
234
    }
235
236
    /**
237
     * @param RangeSetInterface $rangeSet
238
     * @return bool
239
     */
240 5
    public function equals(RangeSetInterface $rangeSet): bool
241
    {
242 5
        $ranges = $rangeSet->getRanges();
243 5
        if (count($this->ranges) != count($ranges)) {
244 1
            return false;
245
        }
246
247 4
        foreach ($this->ranges as $index => $range) {
248 3
            if (!$ranges[$index]->equals($range)) {
249 1
                return false;
250
            }
251
        }
252
253 3
        return true;
254
    }
255
256
    /**
257
     * @param RangeInterface[] ...$rangeLists
258
     * @return RangeInterface[]|Iterator
259
     * @psalm-return Iterator<int, RangeInterface>
260
     */
261 56
    private function createRangePicker(array ...$rangeLists): Iterator
262
    {
263 56
        $rangeListKeys = array_keys($rangeLists);
264 56
        $indexes = array_fill_keys($rangeListKeys, 0);
265
266 56
        while (true) {
267
            /** @psalm-suppress ImpureFunctionCall See https://github.com/vimeo/psalm/issues/4224 */
268 56
            $selectedRanges = array_map([$this, 'selectRanges'], $rangeLists, $indexes);
269
            /** @psalm-suppress ImpureFunctionCall See https://github.com/vimeo/psalm/issues/4224 */
270 56
            $nextRange = array_reduce($selectedRanges, [$this, 'findRangeWithMinimalStart']);
271 56
            if (!isset($nextRange)) {
272 56
                break;
273
            }
274
275 53
            $nextRangeKey = array_search($nextRange, $selectedRanges);
276 53
            if (false === $nextRangeKey) {
277
                // @codeCoverageIgnoreStart
278
                throw new Exception\RangeNotPickedException();
279
                // @codeCoverageIgnoreEnd
280
            }
281 53
            $indexes[$nextRangeKey]++;
282
283 53
            yield $nextRange;
284
        }
285 56
    }
286
287
    /**
288
     * @param RangeInterface[] $ranges
289
     * @param int              $index
290
     * @return RangeInterface|null
291
     */
292 56
    private function selectRanges(array $ranges, int $index): ?RangeInterface
293
    {
294 56
        return $ranges[$index] ?? null;
295
    }
296
297
    /**
298
     * @param RangeInterface|null $previousRange
299
     * @param RangeInterface|null $range
300
     * @return RangeInterface|null
301
     */
302 56
    private function findRangeWithMinimalStart(?RangeInterface $previousRange, ?RangeInterface $range): ?RangeInterface
303
    {
304 56
        if (isset($previousRange)) {
305 48
            if (!isset($range)) {
306 27
                return $previousRange;
307
            }
308
309 45
            return $previousRange->getStart() < $range->getStart()
310 22
                ? $previousRange
311 45
                : $range;
312
        }
313
314 56
        return $range;
315
    }
316
317
    /**
318
     * {@inheritDoc}
319
     *
320
     * @return RangeInterface[]
321
     */
322 59
    public function getRanges(): array
323
    {
324 59
        return $this->ranges;
325
    }
326
327
    /**
328
     * {@inheritDoc}
329
     *
330
     * @return bool
331
     */
332 2
    public function isEmpty(): bool
333
    {
334 2
        return empty($this->ranges);
335
    }
336
}
337