Passed
Push — master ( 26b605...becedb )
by Edward
02:47
created

RangeSet::createSymmetricDifference()   B

Complexity

Conditions 9
Paths 20

Size

Total Lines 38
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 26
c 1
b 0
f 0
dl 0
loc 38
rs 8.0555
cc 9
nc 20
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Remorhaz\IntRangeSets;
6
7
use Generator;
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
     */
25
    private $ranges = [];
26
27
    /**
28
     * Creates set of ranges that contain all values from given ranges.
29
     *
30
     * @param RangeInterface ...$ranges
31
     * @return RangeSetInterface
32
     * @psalm-pure
33
     */
34
    public static function create(RangeInterface ...$ranges): RangeSetInterface
35
    {
36
        $rangeSet = new self();
37
38
        return $rangeSet->withRanges(...$ranges);
39
    }
40
41
    /**
42
     * Provided ranges must be sorted, must not overlap and must not follow each other without gaps.
43
     * Warning: no checks are made! Use {@see RangeSet::createUnion()} to create set from arbitrary list of ranges.
44
     *
45
     * @param RangeInterface ...$ranges
46
     * @return RangeSetInterface
47
     * @psalm-pure
48
     */
49
    public static function createUnsafe(RangeInterface ...$ranges): RangeSetInterface
50
    {
51
        return new self(...$ranges);
52
    }
53
54
    /**
55
     * @param int[] ...$rangeDataList
56
     * @return RangeInterface[]
57
     * @psalm-pure
58
     */
59
    public static function importRanges(array ...$rangeDataList): array
60
    {
61
        /** @var RangeInterface[] $ranges */
62
        $ranges = array_map([self::class, 'importRange'], $rangeDataList);
63
64
        return $ranges;
65
    }
66
67
    /**
68
     * @param int[] $args
69
     * @return RangeInterface
70
     * @psalm-pure
71
     */
72
    private static function importRange(array $args): RangeInterface
73
    {
74
        return new Range(...$args);
0 ignored issues
show
Bug introduced by
$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

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