diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c3da03..220af176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - diff --git a/README.md b/README.md index ab408d3d..659201e2 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/classes/range.js b/classes/range.js index 51125a01..cd87fb4d 100644 --- a/classes/range.js +++ b/classes/range.js @@ -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)) @@ -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 diff --git a/index.js b/index.js index 54a714a3..57e2ae64 100644 --- a/index.js +++ b/index.js @@ -44,4 +44,5 @@ module.exports = { ltr: require('./ranges/ltr'), intersects: require('./ranges/intersects'), simplifyRange: require('./ranges/simplify'), + subset: require('./ranges/subset'), } diff --git a/internal/re.js b/internal/re.js index 0e8fb528..54d4176d 100644 --- a/internal/re.js +++ b/internal/re.js @@ -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*$') diff --git a/ranges/subset.js b/ranges/subset.js new file mode 100644 index 00000000..6ae29a3c --- /dev/null +++ b/ranges/subset.js @@ -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 diff --git a/test/fixtures/range-parse.js b/test/fixtures/range-parse.js index 65744c4b..be7feffd 100644 --- a/test/fixtures/range-parse.js +++ b/test/fixtures/range-parse.js @@ -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'], diff --git a/test/ranges/subset.js b/test/ranges/subset.js new file mode 100644 index 00000000..586723fe --- /dev/null +++ b/test/ranges/subset.js @@ -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) +})