Skip to content

Commit 100f07a

Browse files
committed
subset(): test if one range is a subset of another
This also removes `>=0.0.0` (or `>=0.0.0-0` in `includePrerelease` mode) from the comparators in a range set, because that is equivalent to a `*`.
1 parent 33daffe commit 100f07a

File tree

8 files changed

+250
-1
lines changed

8 files changed

+250
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# changes log
22

3+
## 7.3.0
4+
5+
* Add `subset(r1, r2)` method to determine if `r1` range is entirely
6+
contained by `r2` range.
7+
38
## 7.2.3
49

510
* Fix handling of `includePrelease` mode where version ranges like `1.0.0 -

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const semverGtr = require('semver/ranges/gtr')
7979
const semverLtr = require('semver/ranges/ltr')
8080
const semverIntersects = require('semver/ranges/intersects')
8181
const simplifyRange = require('semver/ranges/simplify')
82+
const rangeSubset = require('semver/ranges/subset')
8283
```
8384
8485
As a command-line utility:
@@ -455,6 +456,8 @@ strings that they parse.
455456
programmatically, to provide the user with something a bit more
456457
ergonomic. If the provided range is shorter in string-length than the
457458
generated range, then that is returned.
459+
* `subset(subRange, superRange)`: Return `true` if the `subRange` range is
460+
entirely contained by the `superRange` range.
458461

459462
Note that, since ranges may be non-contiguous, a version might not be
460463
greater than a range, less than a range, *or* satisfy a range! For

classes/range.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class Range {
9292
.map(comp => parseComparator(comp, this.options))
9393
.join(' ')
9494
.split(/\s+/)
95+
.map(comp => replaceGTE0(comp, this.options))
9596
// in loose mode, throw out any that are not valid comparators
9697
.filter(this.options.loose ? comp => !!comp.match(compRe) : () => true)
9798
.map(comp => new Comparator(comp, this.options))
@@ -379,6 +380,12 @@ const replaceStars = (comp, options) => {
379380
return comp.trim().replace(re[t.STAR], '')
380381
}
381382

383+
const replaceGTE0 = (comp, options) => {
384+
debug('replaceGTE0', comp, options)
385+
return comp.trim()
386+
.replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], '')
387+
}
388+
382389
// This function is passed to string.replace(re[t.HYPHENRANGE])
383390
// M, m, patch, prerelease, build
384391
// 1.2 - 3.4.5 => >=1.2.0 <=3.4.5

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ module.exports = {
4444
ltr: require('./ranges/ltr'),
4545
intersects: require('./ranges/intersects'),
4646
simplifyRange: require('./ranges/simplify'),
47+
subset: require('./ranges/subset'),
4748
}

internal/re.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,6 @@ createToken('HYPHENRANGELOOSE', `^\\s*(${src[t.XRANGEPLAINLOOSE]})` +
177177

178178
// Star ranges basically just allow anything at all.
179179
createToken('STAR', '(<|>)?=?\\s*\\*')
180+
// >=0.0.0 is like a star
181+
createToken('GTE0', '^\\s*>=\\s*0\.0\.0\\s*$')
182+
createToken('GTE0PRE', '^\\s*>=\\s*0\.0\.0-0\\s*$')

ranges/subset.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
const Range = require('../classes/range.js')
2+
const { ANY } = require('../classes/comparator.js')
3+
const satisfies = require('../functions/satisfies.js')
4+
const compare = require('../functions/compare.js')
5+
6+
// Complex range `r1 || r2 || ...` is a subset of `R1 || R2 || ...` iff:
7+
// - Every simple range `r1, r2, ...` is a subset of some `R1, R2, ...`
8+
//
9+
// Simple range `c1 c2 ...` is a subset of simple range `C1 C2 ...` iff:
10+
// - If c is only the ANY comparator
11+
// - If C is only the ANY comparator, return true
12+
// - Else return false
13+
// - Let EQ be the set of = comparators in c
14+
// - If EQ is more than one, return true (null set)
15+
// - Let GT be the highest > or >= comparator in c
16+
// - Let LT be the lowest < or <= comparator in c
17+
// - If GT and LT, and GT.semver > LT.semver, return true (null set)
18+
// - If EQ
19+
// - If GT, and EQ does not satisfy GT, return true (null set)
20+
// - If LT, and EQ does not satisfy LT, return true (null set)
21+
// - If EQ satisfies every C, return true
22+
// - Else return false
23+
// - If GT
24+
// - If GT is lower than any > or >= comp in C, return false
25+
// - If GT is >=, and GT.semver does not satisfy every C, return false
26+
// - If LT
27+
// - If LT.semver is greater than that of any > comp in C, return false
28+
// - If LT is <=, and LT.semver does not satisfy every C, return false
29+
// - If any C is a = range, and GT or LT are set, return false
30+
// - Else return true
31+
32+
const subset = (sub, dom, options) => {
33+
sub = new Range(sub, options)
34+
dom = new Range(dom, options)
35+
let sawNonNull = false
36+
37+
OUTER: for (const simpleSub of sub.set) {
38+
for (const simpleDom of dom.set) {
39+
const isSub = simpleSubset(simpleSub, simpleDom, options)
40+
sawNonNull = sawNonNull || isSub !== null
41+
if (isSub)
42+
continue OUTER
43+
}
44+
// the null set is a subset of everything, but null simple ranges in
45+
// a complex range should be ignored. so if we saw a non-null range,
46+
// then we know this isn't a subset, but if EVERY simple range was null,
47+
// then it is a subset.
48+
if (sawNonNull)
49+
return false
50+
}
51+
return true
52+
}
53+
54+
const simpleSubset = (sub, dom, options) => {
55+
if (sub.length === 1 && sub[0].semver === ANY)
56+
return dom.length === 1 && dom[0].semver === ANY
57+
58+
const eqSet = new Set()
59+
let gt, lt
60+
for (const c of sub) {
61+
if (c.operator === '>' || c.operator === '>=')
62+
gt = higherGT(gt, c, options)
63+
else if (c.operator === '<' || c.operator === '<=')
64+
lt = lowerLT(lt, c, options)
65+
else
66+
eqSet.add(c.semver)
67+
}
68+
69+
if (eqSet.size > 1)
70+
return null
71+
72+
let gtltComp
73+
if (gt && lt) {
74+
gtltComp = compare(gt.semver, lt.semver, options)
75+
if (gtltComp > 0)
76+
return null
77+
else if (gtltComp === 0 && (gt.operator !== '>=' || lt.operator !== '<='))
78+
return null
79+
}
80+
81+
// will iterate one or zero times
82+
for (const eq of eqSet) {
83+
if (gt && !satisfies(eq, String(gt), options))
84+
return null
85+
86+
if (lt && !satisfies(eq, String(lt), options))
87+
return null
88+
89+
for (const c of dom) {
90+
if (!satisfies(eq, String(c), options))
91+
return false
92+
}
93+
return true
94+
}
95+
96+
let higher, lower
97+
let hasDomLT, hasDomGT
98+
for (const c of dom) {
99+
hasDomGT = hasDomGT || c.operator === '>' || c.operator === '>='
100+
hasDomLT = hasDomLT || c.operator === '<' || c.operator === '<='
101+
if (gt) {
102+
if (c.operator === '>' || c.operator === '>=') {
103+
higher = higherGT(gt, c, options)
104+
if (higher === c)
105+
return false
106+
} else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options))
107+
return false
108+
}
109+
if (lt) {
110+
if (c.operator === '<' || c.operator === '<=') {
111+
lower = lowerLT(lt, c, options)
112+
if (lower === c)
113+
return false
114+
} else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options))
115+
return false
116+
}
117+
if (!c.operator && (lt || gt) && gtltComp !== 0)
118+
return false
119+
}
120+
121+
// if there was a < or >, and nothing in the dom, then must be false
122+
// UNLESS it was limited by another range in the other direction.
123+
// Eg, >1.0.0 <1.0.1 is still a subset of <2.0.0
124+
if (gt && hasDomLT && !lt && gtltComp !== 0)
125+
return false
126+
127+
if (lt && hasDomGT && !gt && gtltComp !== 0)
128+
return false
129+
130+
return true
131+
}
132+
133+
// >=1.2.3 is lower than >1.2.3
134+
const higherGT = (a, b, options) => {
135+
if (!a)
136+
return b
137+
const comp = compare(a.semver, b.semver, options)
138+
return comp > 0 ? a
139+
: comp < 0 ? b
140+
: b.operator === '>' && a.operator === '>=' ? b
141+
: a
142+
}
143+
144+
// <=1.2.3 is higher than <1.2.3
145+
const lowerLT = (a, b, options) => {
146+
if (!a)
147+
return b
148+
const comp = compare(a.semver, b.semver, options)
149+
return comp < 0 ? a
150+
: comp > 0 ? b
151+
: b.operator === '<' && a.operator === '<=' ? b
152+
: a
153+
}
154+
155+
module.exports = subset

test/fixtures/range-parse.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ module.exports = [
5858
['~> 1', '>=1.0.0 <2.0.0'],
5959
['~1.0', '>=1.0.0 <1.1.0'],
6060
['~ 1.0', '>=1.0.0 <1.1.0'],
61-
['^0', '>=0.0.0 <1.0.0'],
61+
['^0', '<1.0.0'],
6262
['^ 1', '>=1.0.0 <2.0.0'],
6363
['^0.1', '>=0.1.0 <0.2.0'],
6464
['^1.0', '>=1.0.0 <2.0.0'],

test/ranges/subset.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const t = require('tap')
2+
const subset = require('../../ranges/subset.js')
3+
4+
// sub, dom, expect, [options]
5+
const cases = [
6+
['1.2.3', '1.2.3', true],
7+
['1.2.3 1.2.4', '1.2.3', true],
8+
['1.2.3 2.3.4 || 2.3.4', '3', false],
9+
['^1.2.3-pre.0', '1.x', false],
10+
['^1.2.3-pre.0', '1.x', true, { includePrerelease: true }],
11+
['>2 <1', '3', true],
12+
['1 || 2 || 3', '>=1.0.0', true],
13+
14+
['*', '*', true],
15+
['', '*', true],
16+
['*', '', true],
17+
['', '', true],
18+
19+
// >=0.0.0 is like * in non-prerelease mode
20+
// >=0.0.0-0 is like * in prerelease mode
21+
['*', '>=0.0.0-0', true, { includePrerelease: true }],
22+
['*', '>=0.0.0', true],
23+
['*', '>=0.0.0', false, { includePrerelease: true }],
24+
['*', '>=0.0.0-0', false],
25+
['^2 || ^3 || ^4', '>=1', true],
26+
['^2 || ^3 || ^4', '>1', true],
27+
['^2 || ^3 || ^4', '>=2', true],
28+
['^2 || ^3 || ^4', '>=3', false],
29+
['>=1', '^2 || ^3 || ^4', false],
30+
['>1', '^2 || ^3 || ^4', false],
31+
['>=2', '^2 || ^3 || ^4', false],
32+
['>=3', '^2 || ^3 || ^4', false],
33+
['^1', '^2 || ^3 || ^4', false],
34+
['^2', '^2 || ^3 || ^4', true],
35+
['^3', '^2 || ^3 || ^4', true],
36+
['^4', '^2 || ^3 || ^4', true],
37+
['1.x', '^2 || ^3 || ^4', false],
38+
['2.x', '^2 || ^3 || ^4', true],
39+
['3.x', '^2 || ^3 || ^4', true],
40+
['4.x', '^2 || ^3 || ^4', true],
41+
42+
['>=1.0.0 <=1.0.0 || 2.0.0', '1.0.0 || 2.0.0', true],
43+
['<=1.0.0 >=1.0.0 || 2.0.0', '1.0.0 || 2.0.0', true],
44+
['>=1.0.0', '1.0.0', false],
45+
['>=1.0.0 <2.0.0', '<2.0.0', true],
46+
['>=1.0.0 <2.0.0', '>0.0.0', true],
47+
['>=1.0.0 <=1.0.0', '1.0.0', true],
48+
['>=1.0.0 <=1.0.0', '2.0.0', false],
49+
['<2.0.0', '>=1.0.0 <2.0.0', false],
50+
['>=1.0.0', '>=1.0.0 <2.0.0', false],
51+
['>=1.0.0 <2.0.0', '<2.0.0', true],
52+
['>=1.0.0 <2.0.0', '>=1.0.0', true],
53+
['>=1.0.0 <2.0.0', '>1.0.0', false],
54+
['>=1.0.0 <=2.0.0', '<2.0.0', false],
55+
['>=1.0.0', '<1.0.0', false],
56+
['<=1.0.0', '>1.0.0', false],
57+
['<=1.0.0 >1.0.0', '>1.0.0', true],
58+
['1.0.0 >1.0.0', '>1.0.0', true],
59+
['1.0.0 <1.0.0', '>1.0.0', true],
60+
['<1 <2 <3', '<4', true],
61+
['<3 <2 <1', '<4', true],
62+
['>1 >2 >3', '>0', true],
63+
['>3 >2 >1', '>0', true],
64+
['<=1 <=2 <=3', '<4', true],
65+
['<=3 <=2 <=1', '<4', true],
66+
['>=1 >=2 >=3', '>0', true],
67+
['>=3 >=2 >=1', '>0', true],
68+
]
69+
70+
t.plan(cases.length)
71+
cases.forEach(([sub, dom, expect, options = {}]) => {
72+
const msg = `${sub || "''"}${dom || "''"} = ${expect}` +
73+
(options ? ' ' + Object.keys(options).join(',') : '')
74+
t.equal(subset(sub, dom, options), expect, msg)
75+
})

0 commit comments

Comments
 (0)