Intervals::haveIntersections()   B
last analyzed

Complexity

Conditions 7
Paths 5

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 13
rs 8.8333
cc 7
nc 5
nop 2
1
<?php
2
3
/*
4
 * This file is part of composer/semver.
5
 *
6
 * (c) Composer <https://github.com/composer>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace Composer\Semver;
13
14
use Composer\Semver\Constraint\Constraint;
15
use Composer\Semver\Constraint\ConstraintInterface;
16
use Composer\Semver\Constraint\MatchAllConstraint;
17
use Composer\Semver\Constraint\MatchNoneConstraint;
18
use Composer\Semver\Constraint\MultiConstraint;
19
20
/**
21
 * Helper class generating intervals from constraints
22
 *
23
 * This contains utilities for:
24
 *
25
 *  - compacting an existing constraint which can be used to combine several into one
26
 * by creating a MultiConstraint out of the many constraints you have.
27
 *
28
 *  - checking whether one subset is a subset of another.
29
 *
30
 * Note: You should call clear to free memoization memory  usage when you are done using this class
31
 */
32
class Intervals
33
{
34
    /**
35
     * @phpstan-var array<string, array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}}>
36
     */
37
    private static $intervalsCache = array();
38
39
    /**
40
     * @phpstan-var array<string, int>
41
     */
42
    private static $opSortOrder = array(
43
        '>=' => -3,
44
        '<' => -2,
45
        '>' => 2,
46
        '<=' => 3,
47
    );
48
49
    /**
50
     * Clears the memoization cache once you are done
51
     *
52
     * @return void
53
     */
54
    public static function clear()
55
    {
56
        self::$intervalsCache = array();
57
    }
58
59
    /**
60
     * Checks whether $candidate is a subset of $constraint
61
     *
62
     * @return bool
63
     */
64
    public static function isSubsetOf(ConstraintInterface $candidate, ConstraintInterface $constraint)
65
    {
66
        if ($constraint instanceof MatchAllConstraint) {
67
            return true;
68
        }
69
70
        if ($candidate instanceof MatchNoneConstraint || $constraint instanceof MatchNoneConstraint) {
71
            return false;
72
        }
73
74
        $intersectionIntervals = self::get(new MultiConstraint(array($candidate, $constraint), true));
75
        $candidateIntervals = self::get($candidate);
76
        if (\count($intersectionIntervals['numeric']) !== \count($candidateIntervals['numeric'])) {
77
            return false;
78
        }
79
80
        foreach ($intersectionIntervals['numeric'] as $index => $interval) {
81
            if (!isset($candidateIntervals['numeric'][$index])) {
82
                return false;
83
            }
84
85
            if ((string) $candidateIntervals['numeric'][$index]->getStart() !== (string) $interval->getStart()) {
86
                return false;
87
            }
88
89
            if ((string) $candidateIntervals['numeric'][$index]->getEnd() !== (string) $interval->getEnd()) {
90
                return false;
91
            }
92
        }
93
94
        if ($intersectionIntervals['branches']['exclude'] !== $candidateIntervals['branches']['exclude']) {
95
            return false;
96
        }
97
        if (\count($intersectionIntervals['branches']['names']) !== \count($candidateIntervals['branches']['names'])) {
98
            return false;
99
        }
100
        foreach ($intersectionIntervals['branches']['names'] as $index => $name) {
101
            if ($name !== $candidateIntervals['branches']['names'][$index]) {
102
                return false;
103
            }
104
        }
105
106
        return true;
107
    }
108
109
    /**
110
     * Checks whether $a and $b have any intersection, equivalent to $a->matches($b)
111
     *
112
     * @return bool
113
     */
114
    public static function haveIntersections(ConstraintInterface $a, ConstraintInterface $b)
115
    {
116
        if ($a instanceof MatchAllConstraint || $b instanceof MatchAllConstraint) {
117
            return true;
118
        }
119
120
        if ($a instanceof MatchNoneConstraint || $b instanceof MatchNoneConstraint) {
121
            return false;
122
        }
123
124
        $intersectionIntervals = self::generateIntervals(new MultiConstraint(array($a, $b), true), true);
125
126
        return \count($intersectionIntervals['numeric']) > 0 || $intersectionIntervals['branches']['exclude'] || \count($intersectionIntervals['branches']['names']) > 0;
127
    }
128
129
    /**
130
     * Attempts to optimize a MultiConstraint
131
     *
132
     * When merging MultiConstraints together they can get very large, this will
133
     * compact it by looking at the real intervals covered by all the constraints
134
     * and then creates a new constraint containing only the smallest amount of rules
135
     * to match the same intervals.
136
     *
137
     * @return ConstraintInterface
138
     */
139
    public static function compactConstraint(ConstraintInterface $constraint)
140
    {
141
        if (!$constraint instanceof MultiConstraint) {
142
            return $constraint;
143
        }
144
145
        $intervals = self::generateIntervals($constraint);
146
        $constraints = array();
147
        $hasNumericMatchAll = false;
148
149
        if (\count($intervals['numeric']) === 1 && (string) $intervals['numeric'][0]->getStart() === (string) Interval::fromZero() && (string) $intervals['numeric'][0]->getEnd() === (string) Interval::untilPositiveInfinity()) {
150
            $constraints[] = $intervals['numeric'][0]->getStart();
151
            $hasNumericMatchAll = true;
152
        } else {
153
            $unEqualConstraints = array();
154
            for ($i = 0, $count = \count($intervals['numeric']); $i < $count; $i++) {
155
                $interval = $intervals['numeric'][$i];
156
157
                // if current interval ends with < N and next interval begins with > N we can swap this out for != N
158
                // but this needs to happen as a conjunctive expression together with the start of the current interval
159
                // and end of next interval, so [>=M, <N] || [>N, <P] => [>=M, !=N, <P] but M/P can be skipped if
160
                // they are zero/+inf
161
                if ($interval->getEnd()->getOperator() === '<' && $i+1 < $count) {
162
                    $nextInterval = $intervals['numeric'][$i+1];
163
                    if ($interval->getEnd()->getVersion() === $nextInterval->getStart()->getVersion() && $nextInterval->getStart()->getOperator() === '>') {
164
                        // only add a start if we didn't already do so, can be skipped if we're looking at second
165
                        // interval in [>=M, <N] || [>N, <P] || [>P, <Q] where unEqualConstraints currently contains
166
                        // [>=M, !=N] already and we only want to add !=P right now
167
                        if (\count($unEqualConstraints) === 0 && (string) $interval->getStart() !== (string) Interval::fromZero()) {
168
                            $unEqualConstraints[] = $interval->getStart();
169
                        }
170
                        $unEqualConstraints[] = new Constraint('!=', $interval->getEnd()->getVersion());
171
                        continue;
172
                    }
173
                }
174
175
                if (\count($unEqualConstraints) > 0) {
176
                    // this is where the end of the following interval of a != constraint is added as explained above
177
                    if ((string) $interval->getEnd() !== (string) Interval::untilPositiveInfinity()) {
178
                        $unEqualConstraints[] = $interval->getEnd();
179
                    }
180
181
                    // count is 1 if entire constraint is just one != expression
182
                    if (\count($unEqualConstraints) > 1) {
183
                        $constraints[] = new MultiConstraint($unEqualConstraints, true);
184
                    } else {
185
                        $constraints[] = $unEqualConstraints[0];
186
                    }
187
188
                    $unEqualConstraints = array();
189
                    continue;
190
                }
191
192
                // convert back >= x - <= x intervals to == x
193
                if ($interval->getStart()->getVersion() === $interval->getEnd()->getVersion() && $interval->getStart()->getOperator() === '>=' && $interval->getEnd()->getOperator() === '<=') {
194
                    $constraints[] = new Constraint('==', $interval->getStart()->getVersion());
195
                    continue;
196
                }
197
198
                if ((string) $interval->getStart() === (string) Interval::fromZero()) {
199
                    $constraints[] = $interval->getEnd();
200
                } elseif ((string) $interval->getEnd() === (string) Interval::untilPositiveInfinity()) {
201
                    $constraints[] = $interval->getStart();
202
                } else {
203
                    $constraints[] = new MultiConstraint(array($interval->getStart(), $interval->getEnd()), true);
204
                }
205
            }
206
        }
207
208
        $devConstraints = array();
209
210
        if (0 === \count($intervals['branches']['names'])) {
211
            if ($intervals['branches']['exclude']) {
212
                if ($hasNumericMatchAll) {
213
                    return new MatchAllConstraint;
214
                }
215
                // otherwise constraint should contain a != operator and already cover this
216
            }
217
        } else {
218
            foreach ($intervals['branches']['names'] as $branchName) {
219
                if ($intervals['branches']['exclude']) {
220
                    $devConstraints[] = new Constraint('!=', $branchName);
221
                } else {
222
                    $devConstraints[] = new Constraint('==', $branchName);
223
                }
224
            }
225
226
            // excluded branches, e.g. != dev-foo are conjunctive with the interval, so
227
            // > 2.0 != dev-foo must return a conjunctive constraint
228
            if ($intervals['branches']['exclude']) {
229
                if (\count($constraints) > 1) {
230
                    return new MultiConstraint(array_merge(
231
                        array(new MultiConstraint($constraints, false)),
232
                        $devConstraints
233
                    ), true);
234
                }
235
236
                if (\count($constraints) === 1 && (string)$constraints[0] === (string)Interval::fromZero()) {
237
                    if (\count($devConstraints) > 1) {
238
                        return new MultiConstraint($devConstraints, true);
239
                    }
240
                    return $devConstraints[0];
241
                }
242
243
                return new MultiConstraint(array_merge($constraints, $devConstraints), true);
244
            }
245
246
            // otherwise devConstraints contains a list of == operators for branches which are disjunctive with the
247
            // rest of the constraint
248
            $constraints = array_merge($constraints, $devConstraints);
249
        }
250
251
        if (\count($constraints) > 1) {
252
            return new MultiConstraint($constraints, false);
253
        }
254
255
        if (\count($constraints) === 1) {
256
            return $constraints[0];
257
        }
258
259
        return new MatchNoneConstraint;
260
    }
261
262
    /**
263
     * Creates an array of numeric intervals and branch constraints representing a given constraint
264
     *
265
     * if the returned numeric array is empty it means the constraint matches nothing in the numeric range (0 - +inf)
266
     * if the returned branches array is empty it means no dev-* versions are matched
267
     * if a constraint matches all possible dev-* versions, branches will contain Interval::anyDev()
268
     *
269
     * @return array
270
     * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}}
271
     */
272
    public static function get(ConstraintInterface $constraint)
273
    {
274
        $key = (string) $constraint;
275
276
        if (!isset(self::$intervalsCache[$key])) {
277
            self::$intervalsCache[$key] = self::generateIntervals($constraint);
278
        }
279
280
        return self::$intervalsCache[$key];
281
    }
282
283
    /**
284
     * @param bool $stopOnFirstValidInterval
285
     *
286
     * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}}
287
     */
288
    private static function generateIntervals(ConstraintInterface $constraint, $stopOnFirstValidInterval = false)
289
    {
290
        if ($constraint instanceof MatchAllConstraint) {
291
            return array('numeric' => array(new Interval(Interval::fromZero(), Interval::untilPositiveInfinity())), 'branches' => Interval::anyDev());
292
        }
293
294
        if ($constraint instanceof MatchNoneConstraint) {
295
            return array('numeric' => array(), 'branches' => array('names' => array(), 'exclude' => false));
296
        }
297
298
        if ($constraint instanceof Constraint) {
299
            return self::generateSingleConstraintIntervals($constraint);
300
        }
301
302
        if (!$constraint instanceof MultiConstraint) {
303
            throw new \UnexpectedValueException('The constraint passed in should be an MatchAllConstraint, Constraint or MultiConstraint instance, got '.\get_class($constraint).'.');
304
        }
305
306
        $constraints = $constraint->getConstraints();
307
308
        $numericGroups = array();
309
        $constraintBranches = array();
310
        foreach ($constraints as $c) {
311
            $res = self::get($c);
312
            $numericGroups[] = $res['numeric'];
313
            $constraintBranches[] = $res['branches'];
314
        }
315
316
        if ($constraint->isDisjunctive()) {
317
            $branches = Interval::noDev();
318
            foreach ($constraintBranches as $b) {
319
                if ($b['exclude']) {
320
                    if ($branches['exclude']) {
321
                        // disjunctive constraint, so only exclude what's excluded in all constraints
322
                        // !=a,!=b || !=b,!=c => !=b
323
                        $branches['names'] = array_intersect($branches['names'], $b['names']);
324
                    } else {
325
                        // disjunctive constraint so exclude all names which are not explicitly included in the alternative
326
                        // (==b || ==c) || !=a,!=b => !=a
327
                        $branches['exclude'] = true;
328
                        $branches['names'] = array_diff($b['names'], $branches['names']);
329
                    }
330
                } else {
331
                    if ($branches['exclude']) {
332
                        // disjunctive constraint so exclude all names which are not explicitly included in the alternative
333
                        // !=a,!=b || (==b || ==c) => !=a
334
                        $branches['names'] = array_diff($branches['names'], $b['names']);
335
                    } else {
336
                        // disjunctive constraint, so just add all the other branches
337
                        // (==a || ==b) || ==c => ==a || ==b || ==c
338
                        $branches['names'] = array_merge($branches['names'], $b['names']);
339
                    }
340
                }
341
            }
342
        } else {
343
            $branches = Interval::anyDev();
344
            foreach ($constraintBranches as $b) {
345
                if ($b['exclude']) {
346
                    if ($branches['exclude']) {
347
                        // conjunctive, so just add all branch names to be excluded
348
                        // !=a && !=b => !=a,!=b
349
                        $branches['names'] = array_merge($branches['names'], $b['names']);
350
                    } else {
351
                        // conjunctive, so only keep included names which are not excluded
352
                        // (==a||==c) && !=a,!=b => ==c
353
                        $branches['names'] = array_diff($branches['names'], $b['names']);
354
                    }
355
                } else {
356
                    if ($branches['exclude']) {
357
                        // conjunctive, so only keep included names which are not excluded
358
                        // !=a,!=b && (==a||==c) => ==c
359
                        $branches['names'] = array_diff($b['names'], $branches['names']);
360
                        $branches['exclude'] = false;
361
                    } else {
362
                        // conjunctive, so only keep names that are included in both
363
                        // (==a||==b) && (==a||==c) => ==a
364
                        $branches['names'] = array_intersect($branches['names'], $b['names']);
365
                    }
366
                }
367
            }
368
        }
369
370
        $branches['names'] = array_unique($branches['names']);
371
372
        if (\count($numericGroups) === 1) {
373
            return array('numeric' => $numericGroups[0], 'branches' => $branches);
374
        }
375
376
        $borders = array();
377
        foreach ($numericGroups as $group) {
378
            foreach ($group as $interval) {
379
                $borders[] = array('version' => $interval->getStart()->getVersion(), 'operator' => $interval->getStart()->getOperator(), 'side' => 'start');
380
                $borders[] = array('version' => $interval->getEnd()->getVersion(), 'operator' => $interval->getEnd()->getOperator(), 'side' => 'end');
381
            }
382
        }
383
384
        $opSortOrder = self::$opSortOrder;
385
        usort($borders, function ($a, $b) use ($opSortOrder) {
386
            $order = version_compare($a['version'], $b['version']);
387
            if ($order === 0) {
388
                return $opSortOrder[$a['operator']] - $opSortOrder[$b['operator']];
389
            }
390
391
            return $order;
392
        });
393
394
        $activeIntervals = 0;
395
        $intervals = array();
396
        $index = 0;
397
        $activationThreshold = $constraint->isConjunctive() ? \count($numericGroups) : 1;
398
        $start = null;
399
        foreach ($borders as $border) {
400
            if ($border['side'] === 'start') {
401
                $activeIntervals++;
402
            } else {
403
                $activeIntervals--;
404
            }
405
            if (!$start && $activeIntervals >= $activationThreshold) {
406
                $start = new Constraint($border['operator'], $border['version']);
407
            } elseif ($start && $activeIntervals < $activationThreshold) {
408
                // filter out invalid intervals like > x - <= x, or >= x - < x
409
                if (
410
                    version_compare($start->getVersion(), $border['version'], '=')
411
                    && (
412
                        ($start->getOperator() === '>' && $border['operator'] === '<=')
413
                        || ($start->getOperator() === '>=' && $border['operator'] === '<')
414
                    )
415
                ) {
416
                    unset($intervals[$index]);
417
                } else {
418
                    $intervals[$index] = new Interval($start, new Constraint($border['operator'], $border['version']));
0 ignored issues
show
Bug introduced by
$start of type void is incompatible with the type Composer\Semver\Constraint\Constraint expected by parameter $start of Composer\Semver\Interval::__construct(). ( Ignorable by Annotation )

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

418
                    $intervals[$index] = new Interval(/** @scrutinizer ignore-type */ $start, new Constraint($border['operator'], $border['version']));
Loading history...
419
                    $index++;
420
421
                    if ($stopOnFirstValidInterval) {
422
                        break;
423
                    }
424
                }
425
426
                $start = null;
427
            }
428
        }
429
430
        return array('numeric' => $intervals, 'branches' => $branches);
431
    }
432
433
    /**
434
     * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}}}
435
     */
436
    private static function generateSingleConstraintIntervals(Constraint $constraint)
437
    {
438
        $op = $constraint->getOperator();
439
440
        // handle branch constraints first
441
        if (strpos($constraint->getVersion(), 'dev-') === 0) {
442
            $intervals = array();
443
            $branches = array('names' => array(), 'exclude' => false);
444
445
            // != dev-foo means any numeric version may match, we treat >/< like != they are not really defined for branches
446
            if ($op === '!=') {
447
                $intervals[] = new Interval(Interval::fromZero(), Interval::untilPositiveInfinity());
448
                $branches = array('names' => array($constraint->getVersion()), 'exclude' => true);
449
            } elseif ($op === '==') {
450
                $branches['names'][] = $constraint->getVersion();
451
            }
452
453
            return array(
454
                'numeric' => $intervals,
455
                'branches' => $branches,
456
            );
457
        }
458
459
        if ($op[0] === '>') { // > & >=
460
            return array('numeric' => array(new Interval($constraint, Interval::untilPositiveInfinity())), 'branches' => Interval::noDev());
461
        }
462
        if ($op[0] === '<') { // < & <=
463
            return array('numeric' => array(new Interval(Interval::fromZero(), $constraint)), 'branches' => Interval::noDev());
464
        }
465
        if ($op === '!=') {
466
            // convert !=x to intervals of 0 - <x && >x - +inf + dev*
467
            return array('numeric' => array(
468
                new Interval(Interval::fromZero(), new Constraint('<', $constraint->getVersion())),
469
                new Interval(new Constraint('>', $constraint->getVersion()), Interval::untilPositiveInfinity()),
470
            ), 'branches' => Interval::anyDev());
471
        }
472
473
        // convert ==x to an interval of >=x - <=x
474
        return array('numeric' => array(
475
            new Interval(new Constraint('>=', $constraint->getVersion()), new Constraint('<=', $constraint->getVersion())),
476
        ), 'branches' => Interval::noDev());
477
    }
478
}
479