From 70164addd8f21dcb99076a1185d729c2199695bc Mon Sep 17 00:00:00 2001 From: Suneil Nyamathi Date: Tue, 13 Jun 2017 21:09:18 +0000 Subject: [PATCH] Add project files --- .eslintignore | 1 + .eslintrc | 7 ++ .gitignore | 5 + .npmignore | 8 ++ package.json | 29 ++++++ semver-intersect.js | 125 +++++++++++++++++++++++++ tests/unit/semver-intersect.js | 162 +++++++++++++++++++++++++++++++++ 7 files changed, 337 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 package.json create mode 100644 semver-intersect.js create mode 100644 tests/unit/semver-intersect.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..de153db --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +artifacts diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..177701b --- /dev/null +++ b/.eslintrc @@ -0,0 +1,7 @@ +extends: eslint:recommended +env: + es6: true + mocha: true + node: true +rules: + semi: 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43eda07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.log +*.tgz +.nyc_output +artifacts +node_modules diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..72e1e76 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +*.log +*.tgz +.eslintignore +.eslintrc +.npmignore +.nyc_output +artifacts +tests diff --git a/package.json b/package.json new file mode 100644 index 0000000..112a8a8 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "author": "Suneil Nyamathi ", + "bugs": "http://github.com/snyamathi/semver-intersect/issues", + "contributors": [], + "dependencies": { + "semver": "^5.0.0" + }, + "description": "Get the intersection of multiple semver ranges", + "devDependencies": { + "chai": "^4.0.0", + "eslint": "^4.0.0", + "jenkins-mocha": "^4.0.0" + }, + "keywords": [ + "semver" + ], + "license": "MIT", + "main": "semver-intersect.js", + "name": "semver-intersect", + "repository": { + "type": "git", + "url": "git@github.com/snyamathi/semver-intersect" + }, + "scripts": { + "lint": "eslint .", + "test": "jenkins-mocha tests/unit --recursive" + }, + "version": "1.0.0" +} diff --git a/semver-intersect.js b/semver-intersect.js new file mode 100644 index 0000000..9afc0d8 --- /dev/null +++ b/semver-intersect.js @@ -0,0 +1,125 @@ +const semver = require('semver'); +const regex = { + condition: /^([<=>]+)?/, + majorVersion: /\d+/, + minMax: /^>=([\d]+\.[\d]+\.[\d]+(?:-[\w.]+)?) <([\d]+\.[\d]+\.[\d]+)$/, + version: /([\d]+\.[\d]+\.[\d]+(?:-[\w.]+)?)$/, + whitespace: /\s+/ +}; + +function createShorthand (range) { + const match = regex.minMax.exec(range); + if (!match) { + return range; + } + + const [ min, max ] = match.slice(1); + if (semver.major(min) !== semver.major(max)) { + return `^${min}`; + } + + return `~${min}`; +} + +function ensureCompatible(range, ...bounds) { + const { version } = parseRange(range); + bounds.forEach(bound => { + if (bound && !semver.satisfies(version, bound)) { + throw new Error(`Range ${range} is not compatible with ${bound}`); + } + }); +} + +function expandRanges (...ranges) { + return ranges.reduce((result, range) => { + const validRange = semver.validRange(range); + const validRanges = validRange.split(regex.whitespace); + return union(result, validRanges); + }, []); +} + +function formatIntersection ({ lowerBound = '', upperBound = '' }) { + if (lowerBound === upperBound) { + return lowerBound; + } + + return `${lowerBound} ${upperBound}`.trim(); +} + +function intersect (...ranges) { + ranges = expandRanges(...ranges); + + const bounds = ranges.reduce(({ lowerBound, upperBound }, range) => { + const { condition } = parseRange(range); + + // Exact version number specified, must be compatible with both bounds + if (condition === '=') { + ensureCompatible(range, lowerBound, upperBound); + lowerBound = upperBound = range; + } + + // New lower bound must be less than existing upper bound + if (condition.startsWith('>')) { + ensureCompatible(range, upperBound); + lowerBound = mergeBounds(range, lowerBound); + } + + // And vice versa + if (condition.startsWith('<')) { + ensureCompatible(range, lowerBound); + upperBound = mergeBounds(range, upperBound); + } + + return { lowerBound, upperBound }; + }, {}); + + const range = formatIntersection(bounds); + const shorthand = createShorthand(range); + + return shorthand; +} + +function mergeBounds (range, bound) { + if (!bound) { + return range; + } + + const { condition, version } = parseRange(range); + const boundingVersion = parseRange(bound).version; + const comparator = condition.startsWith('<') ? semver.lt : semver.gt; + const strict = condition === '<' || condition === '>'; + + if (comparator(version, boundingVersion)) { + return range; + } else if (strict && semver.eq(version, boundingVersion)) { + return range; + } else { + return bound; + } +} + +function parseRange (range) { + const condition = regex.condition.exec(range)[1] || '='; + const version = regex.version.exec(range)[1]; + return { condition, version }; +} + +function union (a, b) { + return b.reduce((result, value) => { + if (!result.includes(value)) { + result.push(value); + } + return result; + }, a); +} + +module.exports.default = intersect; + +module.exports.createShorthand = createShorthand; +module.exports.ensureCompatible = ensureCompatible; +module.exports.expandRanges = expandRanges; +module.exports.formatIntersection = formatIntersection; +module.exports.intersect = intersect; +module.exports.mergeBounds = mergeBounds; +module.exports.parseRange = parseRange; +module.exports.union = union; diff --git a/tests/unit/semver-intersect.js b/tests/unit/semver-intersect.js new file mode 100644 index 0000000..1e0c6c5 --- /dev/null +++ b/tests/unit/semver-intersect.js @@ -0,0 +1,162 @@ +const expect = require('chai').expect; +const { + createShorthand, + ensureCompatible, + expandRanges, + formatIntersection, + intersect, + mergeBounds, + parseRange, + union +} = require('../../semver-intersect'); + +describe('createShorthand', () => { + it('should return exact ranges', () => { + const result = createShorthand('4.0.0'); + expect(result).to.equal('4.0.0'); + }); + it('should simplify to a caret range', () => { + const result = createShorthand('>=4.0.0 <5.0.0'); + expect(result).to.equal('^4.0.0'); + }); + it('should simplify to a tilde range', () => { + const result = createShorthand('>=4.0.0 <4.1.0'); + expect(result).to.equal('~4.0.0'); + }); + it('should simplify with a pre-release tag', () => { + const result = createShorthand('>=4.0.0-alpha <5.0.0'); + expect(result).to.equal('^4.0.0-alpha'); + }); + it('should simplify with a pre-release tag and identifier', () => { + const result = createShorthand('>=4.0.0-beta.1 <4.1.0'); + expect(result).to.equal('~4.0.0-beta.1'); + }); + it('should return granular ranges without changes', () => { + [ + '>4.0.0', + '<4.0.0', + '<=4.0.0', + '>=4.0.0', + '4.0.0 - 4.0.5', + '>4.0.0 <4.9.1' + ].forEach(range => { + const result = createShorthand(range); + expect(result).to.equal(range); + }); + }); +}); + +describe('ensureCompatible', () => { + it('should throw if an lower bound is higher than the existing higher bound', () => { + const call = ensureCompatible.bind(null, '>=3.0.0', '<2.0.0'); + expect(call).to.throw('Range >=3.0.0 is not compatible with <2.0.0'); + }); + it('should throw if an upper bound is lower than the existing lower bound', () => { + const call = ensureCompatible.bind(null, '<1.0.0', '>=2.0.0'); + expect(call).to.throw('Range <1.0.0 is not compatible with >=2.0.0'); + }); + it('should throw if an exact range is above an existing upper bound', () => { + const call = ensureCompatible.bind(null, '3.0.0', '<2.0.0'); + expect(call).to.throw('Range 3.0.0 is not compatible with <2.0.0'); + }); + it('should throw if an exact range is below an existing lower bound', () => { + const call = ensureCompatible.bind(null, '2.0.0', '>=3.0.0'); + expect(call).to.throw('Range 2.0.0 is not compatible with >=3.0.0'); + }); + it('should not throw if the new range is compatible with existing bounds', () => { + expect(ensureCompatible.bind(null, '2.0.0', '>=1.0.0')).to.not.throw(); + expect(ensureCompatible.bind(null, '2.0.0')).to.not.throw(); + expect(ensureCompatible.bind(null, '>=2.0.0', '<5.0.0')).to.not.throw(); + expect(ensureCompatible.bind(null, '<4.0.0', '>2.0.0')).to.not.throw(); + }); +}); + +describe('expandRanges', () => { + it('should expand the list of ranges into a set of unique individual ranges', () => { + const result = expandRanges('>=3.0.0 <4.0.0', '>=3.1.0 <4.0.0', '>=3.3.0'); + expect(result).to.deep.equal(['>=3.0.0', '<4.0.0', '>=3.1.0', '>=3.3.0']); + }); +}); + +describe('formatIntersection', () => { + it('should return the exact condition if both bounds are the same', () => { + const lowerBound = '3.0.0'; + const upperBound = '3.0.0'; + expect(formatIntersection({ lowerBound, upperBound })).to.equal('3.0.0'); + }); + it('should return the single condition if only one is provided', () => { + const lowerBound = '>=3.0.0'; + const upperBound = '<4.0.0'; + expect(formatIntersection({ lowerBound })).to.equal('>=3.0.0'); + expect(formatIntersection({ upperBound })).to.equal('<4.0.0'); + }); + it('should the two bounds separated by a space', () => { + const lowerBound = '>=3.0.0'; + const upperBound = '<4.0.0'; + expect(formatIntersection({ lowerBound, upperBound })).to.equal('>=3.0.0 <4.0.0'); + }); +}); + +describe('intersect', () => { + it('should return exact ranges', () => { + const result = intersect('4.0.0'); + expect(result).to.equal('4.0.0'); + }); + it('should return a caret range', () => { + const result = intersect('^4.0.0', '^4.3.0'); + expect(result).to.equal('^4.3.0'); + }); + it('should return a tilde range', () => { + const result = intersect('^4.0.0', '~4.3.0'); + expect(result).to.equal('~4.3.0'); + }); + it('should simplify redundant ranges', () => { + const result = intersect('^4.0.0', '~4.3.89', '~4.3.24', '~4.3.63'); + expect(result).to.equal('~4.3.89'); + }); + it('should throw on incompatible ranges', () => { + const call = intersect.bind(null, '^4.0.0', '~4.3.0', '^4.4.0'); + expect(call).to.throw('Range >=4.4.0 is not compatible with <4.4.0'); + }); +}); + +describe('mergeBounds', () => { + it('should return the range if there are no existing bound', () => { + expect(mergeBounds('>=5.0.0')).to.equal('>=5.0.0'); + }); + it('should merge a new lower bound with an existing lower bound', () => { + expect(mergeBounds('>=5.0.0', '>=5.1.0')).to.equal('>=5.1.0'); + expect(mergeBounds('>=5.5.0', '>=5.1.0')).to.equal('>=5.5.0'); + }); + it('should merge a new upper bound with an existing upper bound', () => { + expect(mergeBounds('<4.2.0', '<4.1.0')).to.equal('<4.1.0'); + expect(mergeBounds('<4.0.5', '<4.1.0')).to.equal('<4.0.5'); + }); + it('should replace a loose comparator with a strict comparator', () => { + expect(mergeBounds('<4.1.0', '<=4.1.0')).to.equal('<4.1.0'); + }); +}); + +describe('parseRange', () => { + it('return the comparison condition and version', () => { + expect(parseRange('<5.0.0')).to.deep.equal({ + condition: '<', + version: '5.0.0' + }); + expect(parseRange('>=4.0.0')).to.deep.equal({ + condition: '>=', + version: '4.0.0' + }); + expect(parseRange('3.0.0')).to.deep.equal({ + condition: '=', + version: '3.0.0' + }); + }); +}); + +describe('union', () => { + it('should merge arrays without duplicates', () => { + const result = union([1, 2, 3], [3, 4, 5]); + expect(result).to.deep.equal([1, 2, 3, 4, 5]); + }); +});