Skip to content

Commit

Permalink
subset(): test if one range is a subset of another
Browse files Browse the repository at this point in the history
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
`*`.
  • Loading branch information
isaacs committed Apr 13, 2020
1 parent 33daffe commit 100f07a
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 1 deletion.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# changes log

## 7.3.0

* Add `subset(r1, r2)` method to determine if `r1` range is entirely
contained by `r2` range.

## 7.2.3

* Fix handling of `includePrelease` mode where version ranges like `1.0.0 -
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const semverGtr = require('semver/ranges/gtr')
const semverLtr = require('semver/ranges/ltr')
const semverIntersects = require('semver/ranges/intersects')
const simplifyRange = require('semver/ranges/simplify')
const rangeSubset = require('semver/ranges/subset')
```
As a command-line utility:
Expand Down Expand Up @@ -455,6 +456,8 @@ strings that they parse.
programmatically, to provide the user with something a bit more
ergonomic. If the provided range is shorter in string-length than the
generated range, then that is returned.
* `subset(subRange, superRange)`: Return `true` if the `subRange` range is
entirely contained by the `superRange` range.

Note that, since ranges may be non-contiguous, a version might not be
greater than a range, less than a range, *or* satisfy a range! For
Expand Down
7 changes: 7 additions & 0 deletions classes/range.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class Range {
.map(comp => parseComparator(comp, this.options))
.join(' ')
.split(/\s+/)
.map(comp => replaceGTE0(comp, this.options))
// in loose mode, throw out any that are not valid comparators
.filter(this.options.loose ? comp => !!comp.match(compRe) : () => true)
.map(comp => new Comparator(comp, this.options))
Expand Down Expand Up @@ -379,6 +380,12 @@ const replaceStars = (comp, options) => {
return comp.trim().replace(re[t.STAR], '')
}

const replaceGTE0 = (comp, options) => {
debug('replaceGTE0', comp, options)
return comp.trim()
.replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], '')
}

// This function is passed to string.replace(re[t.HYPHENRANGE])
// M, m, patch, prerelease, build
// 1.2 - 3.4.5 => >=1.2.0 <=3.4.5
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ module.exports = {
ltr: require('./ranges/ltr'),
intersects: require('./ranges/intersects'),
simplifyRange: require('./ranges/simplify'),
subset: require('./ranges/subset'),
}
3 changes: 3 additions & 0 deletions internal/re.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,6 @@ createToken('HYPHENRANGELOOSE', `^\\s*(${src[t.XRANGEPLAINLOOSE]})` +

// Star ranges basically just allow anything at all.
createToken('STAR', '(<|>)?=?\\s*\\*')
// >=0.0.0 is like a star
createToken('GTE0', '^\\s*>=\\s*0\.0\.0\\s*$')
createToken('GTE0PRE', '^\\s*>=\\s*0\.0\.0-0\\s*$')
155 changes: 155 additions & 0 deletions ranges/subset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
const Range = require('../classes/range.js')
const { ANY } = require('../classes/comparator.js')
const satisfies = require('../functions/satisfies.js')
const compare = require('../functions/compare.js')

// Complex range `r1 || r2 || ...` is a subset of `R1 || R2 || ...` iff:
// - Every simple range `r1, r2, ...` is a subset of some `R1, R2, ...`
//
// Simple range `c1 c2 ...` is a subset of simple range `C1 C2 ...` iff:
// - If c is only the ANY comparator
// - If C is only the ANY comparator, return true
// - Else return false
// - Let EQ be the set of = comparators in c
// - If EQ is more than one, return true (null set)
// - Let GT be the highest > or >= comparator in c
// - Let LT be the lowest < or <= comparator in c
// - If GT and LT, and GT.semver > LT.semver, return true (null set)
// - If EQ
// - If GT, and EQ does not satisfy GT, return true (null set)
// - If LT, and EQ does not satisfy LT, return true (null set)
// - If EQ satisfies every C, return true
// - Else return false
// - If GT
// - If GT is lower than any > or >= comp in C, return false
// - If GT is >=, and GT.semver does not satisfy every C, return false
// - If LT
// - If LT.semver is greater than that of any > comp in C, return false
// - If LT is <=, and LT.semver does not satisfy every C, return false
// - If any C is a = range, and GT or LT are set, return false
// - Else return true

const subset = (sub, dom, options) => {
sub = new Range(sub, options)
dom = new Range(dom, options)
let sawNonNull = false

OUTER: for (const simpleSub of sub.set) {
for (const simpleDom of dom.set) {
const isSub = simpleSubset(simpleSub, simpleDom, options)
sawNonNull = sawNonNull || isSub !== null
if (isSub)
continue OUTER
}
// the null set is a subset of everything, but null simple ranges in
// a complex range should be ignored. so if we saw a non-null range,
// then we know this isn't a subset, but if EVERY simple range was null,
// then it is a subset.
if (sawNonNull)
return false
}
return true
}

const simpleSubset = (sub, dom, options) => {
if (sub.length === 1 && sub[0].semver === ANY)
return dom.length === 1 && dom[0].semver === ANY

const eqSet = new Set()
let gt, lt
for (const c of sub) {
if (c.operator === '>' || c.operator === '>=')
gt = higherGT(gt, c, options)
else if (c.operator === '<' || c.operator === '<=')
lt = lowerLT(lt, c, options)
else
eqSet.add(c.semver)
}

if (eqSet.size > 1)
return null

let gtltComp
if (gt && lt) {
gtltComp = compare(gt.semver, lt.semver, options)
if (gtltComp > 0)
return null
else if (gtltComp === 0 && (gt.operator !== '>=' || lt.operator !== '<='))
return null
}

// will iterate one or zero times
for (const eq of eqSet) {
if (gt && !satisfies(eq, String(gt), options))
return null

if (lt && !satisfies(eq, String(lt), options))
return null

for (const c of dom) {
if (!satisfies(eq, String(c), options))
return false
}
return true
}

let higher, lower
let hasDomLT, hasDomGT
for (const c of dom) {
hasDomGT = hasDomGT || c.operator === '>' || c.operator === '>='
hasDomLT = hasDomLT || c.operator === '<' || c.operator === '<='
if (gt) {
if (c.operator === '>' || c.operator === '>=') {
higher = higherGT(gt, c, options)
if (higher === c)
return false
} else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options))
return false
}
if (lt) {
if (c.operator === '<' || c.operator === '<=') {
lower = lowerLT(lt, c, options)
if (lower === c)
return false
} else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options))
return false
}
if (!c.operator && (lt || gt) && gtltComp !== 0)
return false
}

// if there was a < or >, and nothing in the dom, then must be false
// UNLESS it was limited by another range in the other direction.
// Eg, >1.0.0 <1.0.1 is still a subset of <2.0.0
if (gt && hasDomLT && !lt && gtltComp !== 0)
return false

if (lt && hasDomGT && !gt && gtltComp !== 0)
return false

return true
}

// >=1.2.3 is lower than >1.2.3
const higherGT = (a, b, options) => {
if (!a)
return b
const comp = compare(a.semver, b.semver, options)
return comp > 0 ? a
: comp < 0 ? b
: b.operator === '>' && a.operator === '>=' ? b
: a
}

// <=1.2.3 is higher than <1.2.3
const lowerLT = (a, b, options) => {
if (!a)
return b
const comp = compare(a.semver, b.semver, options)
return comp < 0 ? a
: comp > 0 ? b
: b.operator === '<' && a.operator === '<=' ? b
: a
}

module.exports = subset
2 changes: 1 addition & 1 deletion test/fixtures/range-parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ module.exports = [
['~> 1', '>=1.0.0 <2.0.0'],
['~1.0', '>=1.0.0 <1.1.0'],
['~ 1.0', '>=1.0.0 <1.1.0'],
['^0', '>=0.0.0 <1.0.0'],
['^0', '<1.0.0'],
['^ 1', '>=1.0.0 <2.0.0'],
['^0.1', '>=0.1.0 <0.2.0'],
['^1.0', '>=1.0.0 <2.0.0'],
Expand Down
75 changes: 75 additions & 0 deletions test/ranges/subset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const t = require('tap')
const subset = require('../../ranges/subset.js')

// sub, dom, expect, [options]
const cases = [
['1.2.3', '1.2.3', true],
['1.2.3 1.2.4', '1.2.3', true],
['1.2.3 2.3.4 || 2.3.4', '3', false],
['^1.2.3-pre.0', '1.x', false],
['^1.2.3-pre.0', '1.x', true, { includePrerelease: true }],
['>2 <1', '3', true],
['1 || 2 || 3', '>=1.0.0', true],

['*', '*', true],
['', '*', true],
['*', '', true],
['', '', true],

// >=0.0.0 is like * in non-prerelease mode
// >=0.0.0-0 is like * in prerelease mode
['*', '>=0.0.0-0', true, { includePrerelease: true }],
['*', '>=0.0.0', true],
['*', '>=0.0.0', false, { includePrerelease: true }],
['*', '>=0.0.0-0', false],
['^2 || ^3 || ^4', '>=1', true],
['^2 || ^3 || ^4', '>1', true],
['^2 || ^3 || ^4', '>=2', true],
['^2 || ^3 || ^4', '>=3', false],
['>=1', '^2 || ^3 || ^4', false],
['>1', '^2 || ^3 || ^4', false],
['>=2', '^2 || ^3 || ^4', false],
['>=3', '^2 || ^3 || ^4', false],
['^1', '^2 || ^3 || ^4', false],
['^2', '^2 || ^3 || ^4', true],
['^3', '^2 || ^3 || ^4', true],
['^4', '^2 || ^3 || ^4', true],
['1.x', '^2 || ^3 || ^4', false],
['2.x', '^2 || ^3 || ^4', true],
['3.x', '^2 || ^3 || ^4', true],
['4.x', '^2 || ^3 || ^4', true],

['>=1.0.0 <=1.0.0 || 2.0.0', '1.0.0 || 2.0.0', true],
['<=1.0.0 >=1.0.0 || 2.0.0', '1.0.0 || 2.0.0', true],
['>=1.0.0', '1.0.0', false],
['>=1.0.0 <2.0.0', '<2.0.0', true],
['>=1.0.0 <2.0.0', '>0.0.0', true],
['>=1.0.0 <=1.0.0', '1.0.0', true],
['>=1.0.0 <=1.0.0', '2.0.0', false],
['<2.0.0', '>=1.0.0 <2.0.0', false],
['>=1.0.0', '>=1.0.0 <2.0.0', false],
['>=1.0.0 <2.0.0', '<2.0.0', true],
['>=1.0.0 <2.0.0', '>=1.0.0', true],
['>=1.0.0 <2.0.0', '>1.0.0', false],
['>=1.0.0 <=2.0.0', '<2.0.0', false],
['>=1.0.0', '<1.0.0', false],
['<=1.0.0', '>1.0.0', false],
['<=1.0.0 >1.0.0', '>1.0.0', true],
['1.0.0 >1.0.0', '>1.0.0', true],
['1.0.0 <1.0.0', '>1.0.0', true],
['<1 <2 <3', '<4', true],
['<3 <2 <1', '<4', true],
['>1 >2 >3', '>0', true],
['>3 >2 >1', '>0', true],
['<=1 <=2 <=3', '<4', true],
['<=3 <=2 <=1', '<4', true],
['>=1 >=2 >=3', '>0', true],
['>=3 >=2 >=1', '>0', true],
]

t.plan(cases.length)
cases.forEach(([sub, dom, expect, options = {}]) => {
const msg = `${sub || "''"}${dom || "''"} = ${expect}` +
(options ? ' ' + Object.keys(options).join(',') : '')
t.equal(subset(sub, dom, options), expect, msg)
})

0 comments on commit 100f07a

Please sign in to comment.