Skip to content

Commit

Permalink
feat: add satisfies() to test for range match
Browse files Browse the repository at this point in the history
Using ~ and ^ to verify if a version is in the range.
  • Loading branch information
omichelsen committed Nov 12, 2021
1 parent 63dfbd4 commit aad9932
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 52 deletions.
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,6 @@ const sorted = versions.sort(compareVersions);
'1.5.19'
]
*/
const sortDescending = versions.sort(compareVersions).reverse();
/*
[
'1.5.19'
'1.5.5',
'1.2.3',
]
*/
```

### "Human Readable" Compare
Expand All @@ -73,6 +65,24 @@ compare('10.1.1', '10.2.2', '<='); // true
compare('10.1.1', '10.2.2', '>='); // false
```

### Version ranges

The `satisfies` function accepts a range to compare, compatible with [npm package versioning](https://docs.npmjs.com/cli/v6/using-npm/semver):

```js
import { satisfies } from 'compare-versions';

satisfies('10.0.1', '~10.0.0'); // true
satisfies('10.1.0', '~10.0.0'); // false
satisfies('10.1.2', '^10.0.0'); // true
satisfies('11.0.0', '^10.0.0'); // false
satisfies('10.1.8', '>10.0.4'); // true
satisfies('10.0.1', '=10.0.1'); // true
satisfies('10.1.1', '<10.2.2'); // true
satisfies('10.1.1', '<=10.2.2'); // true
satisfies('10.1.1', '>=10.2.2'); // false
```

### Validate version numbers

Applies the same ruleset used comparing version numbers and returns a boolean:
Expand Down
31 changes: 22 additions & 9 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ declare const compareVersions: {

/**
* Compare [semver](https://semver.org/) version strings using the specified operator.
*
*
* @param firstVersion First version to compare
* @param secondVersion Second version to compare
* @param operator Allowed arithmetic operator to use
Expand All @@ -32,15 +32,15 @@ declare const compareVersions: {
* compareVersions.compare('10.1.1', '10.2.2', '>='); // return false
* ```
*/
compare(
firstVersion: string,
secondVersion: string,
compare(
firstVersion: string,
secondVersion: string,
operator: compareVersions.CompareOperator
): boolean;

/**
* Validate [semver](https://semver.org/) version strings.
*
*
* @param version Version number to validate
* @returns `true` if the version number is a valid semver version number, `false` otherwise.
*
Expand All @@ -51,9 +51,22 @@ declare const compareVersions: {
* compareVersions.validate('foo'); // return false
* ```
*/
validate(
version: string
): boolean;
validate(version: string): boolean;

/**
* Match [npm semver](https://docs.npmjs.com/cli/v6/using-npm/semver) version range.
*
* @param version Version number to match
* @param range Range pattern for version
* @returns `true` if the version number is within the range, `false` otherwise.
*
* @example
* ```
* satisfies('1.1.0', '^1.0.0'); // return true
* satisfies('1.1.0', '~1.0.0'); // return false
* ```
*/
satisfies(version: string, range: string): boolean;
};

export = compareVersions;
export = compareVersions;
102 changes: 76 additions & 26 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
} else {
root.compareVersions = factory();
}
}(this, function () {

var semver = /^v?(?:\d+)(\.(?:[x*]|\d+)(\.(?:[x*]|\d+)(\.(?:[x*]|\d+))?(?:-[\da-z\-]+(?:\.[\da-z\-]+)*)?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;
})(this, function () {
var semver =
/^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;

function indexOrEnd(str, q) {
return str.indexOf(q) === -1 ? str.length : str.indexOf(q);
Expand All @@ -25,20 +25,45 @@
}

function tryParse(v) {
return isNaN(Number(v)) ? v : Number(v);
var n = parseInt(v, 10);
return isNaN(n) ? v : n;
}

function validate(version) {
if (typeof version !== 'string') {
function validateAndParse(v) {
if (typeof v !== 'string') {
throw new TypeError('Invalid argument expected string');
}
if (!semver.test(version)) {
throw new Error('Invalid argument not valid semver (\''+version+'\' received)');
var match = v.match(semver);
if (!match) {
throw new Error(
"Invalid argument not valid semver ('" + v + "' received)"
);
}
match.shift();
return match;
}

function forceType(a, b) {
return typeof a !== typeof b ? [String(a), String(b)] : [a, b];
}

function compareStrings(a, b) {
var [ap, bp] = forceType(tryParse(a), tryParse(b));
if (ap > bp) return 1;
if (ap < bp) return -1;
return 0;
}

function compareSegments(a, b) {
for (var i = 0; i < Math.max(a.length, b.length); i++) {
var r = compareStrings(a[i] || 0, b[i] || 0);
if (r !== 0) return r;
}
return 0;
}

function compareVersions(v1, v2) {
[v1, v2].forEach(validate);
[v1, v2].forEach(validateAndParse);

var s1 = split(v1);
var s2 = split(v2);
Expand All @@ -59,8 +84,16 @@
var p2 = sp2.split('.').map(tryParse);

for (i = 0; i < Math.max(p1.length, p2.length); i++) {
if (p1[i] === undefined || typeof p2[i] === 'string' && typeof p1[i] === 'number') return -1;
if (p2[i] === undefined || typeof p1[i] === 'string' && typeof p2[i] === 'number') return 1;
if (
p1[i] === undefined ||
(typeof p2[i] === 'string' && typeof p1[i] === 'number')
)
return -1;
if (
p2[i] === undefined ||
(typeof p1[i] === 'string' && typeof p2[i] === 'number')
)
return 1;

if (p1[i] > p2[i]) return 1;
if (p2[i] > p1[i]) return -1;
Expand All @@ -70,36 +103,34 @@
}

return 0;
};
}

var allowedOperators = [
'>',
'>=',
'=',
'<',
'<='
];
var allowedOperators = ['>', '>=', '=', '<', '<='];

var operatorResMap = {
'>': [1],
'>=': [0, 1],
'=': [0],
'<=': [-1, 0],
'<': [-1]
'<': [-1],
};

function validateOperator(op) {
if (typeof op !== 'string') {
throw new TypeError('Invalid operator type, expected string but got ' + typeof op);
throw new TypeError(
'Invalid operator type, expected string but got ' + typeof op
);
}
if (allowedOperators.indexOf(op) === -1) {
throw new TypeError('Invalid operator, expected one of ' + allowedOperators.join('|'));
throw new TypeError(
'Invalid operator, expected one of ' + allowedOperators.join('|')
);
}
}

compareVersions.validate = function(version) {
compareVersions.validate = function (version) {
return typeof version === 'string' && semver.test(version);
}
};

compareVersions.compare = function (v1, v2, operator) {
// Validate operator
Expand All @@ -109,7 +140,26 @@
// a simple map can be used to replace switch
var res = compareVersions(v1, v2);
return operatorResMap[operator].indexOf(res) > -1;
}
};

compareVersions.satisfies = function (v, r) {
// if no range operator then "="
var match = r.match(/^([<>=~^]+)/);
var op = match ? match[1] : '=';

// if gt/lt/eq then operator compare
if (op !== '^' && op !== '~') return compareVersions.compare(v, r, op);

// else range of either "~" or "^" is assumed
var [v1, v2, v3] = validateAndParse(v);
var [m1, m2, m3] = validateAndParse(r);
if (compareStrings(v1, m1) !== 0) return false;
if (op === '^') {
return compareSegments([v2, v3], [m2, m3]) >= 0;
}
if (compareStrings(v2, m2) !== 0) return false;
return compareStrings(v3, m3) >= 0;
};

return compareVersions;
}));
});
29 changes: 23 additions & 6 deletions index.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export default function compareVersions(v1, v2) {
// validate input and split into segments
const n1 = validateAndParseVersion(v1);
const n2 = validateAndParseVersion(v2);
const n1 = validateAndParse(v1);
const n2 = validateAndParse(v2);

// pop off the patch
const p1 = n1.pop();
Expand All @@ -21,8 +21,7 @@ export default function compareVersions(v1, v2) {
return 0;
}

export const validate = (version) =>
typeof version === 'string' && semver.test(version);
export const validate = (v) => typeof v === 'string' && semver.test(v);

export const compare = (v1, v2, operator) => {
// validate input operator
Expand All @@ -35,10 +34,28 @@ export const compare = (v1, v2, operator) => {
return operatorResMap[operator].includes(res);
};

export const satisfies = (v, r) => {
// if no range operator then "="
const op = r.match(/^([<>=~^]+)/)?.[1] || '=';

// if gt/lt/eq then operator compare
if (op !== '^' && op !== '~') return compare(v, r, op);

// else range of either "~" or "^" is assumed
const [v1, v2, v3] = validateAndParse(v);
const [m1, m2, m3] = validateAndParse(r);
if (compareStrings(v1, m1) !== 0) return false;
if (op === '^') {
return compareSegments([v2, v3], [m2, m3]) >= 0;
}
if (compareStrings(v2, m2) !== 0) return false;
return compareStrings(v3, m3) >= 0;
};

const semver =
/^v?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;
/^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;

const validateAndParseVersion = (v) => {
const validateAndParse = (v) => {
if (typeof v !== 'string') {
throw new TypeError('Invalid argument expected string');
}
Expand Down
59 changes: 59 additions & 0 deletions test/satisfies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from 'assert';
import { satisfies } from '../index.mjs';

describe('validate versions', () => {
[
['1.0.0', '^1', true],
['1.0.0', '^1.0', true],
['1.0.0', '^1.0.0', true],
['1.2.0', '^1.0.0', true],
['v1.2.0', '^1.0.0', true],
['2.0.0', '^1.0.0', false],
['1.0.0', '^1.2.0', false],
['1.0.1', '^1.2.0', false],
['1.3.4', '^1.2.5', true],
['1.0.0', '~1.2.0', false],
['1.0.0', '~1.0.1', false],
['1.0.1', '~1.0.0', true],
['1.3.4', '~1.2.5', false],
['1.0.0', '~1.0.0', true],
['1.0.0-alpha.1', '^1.0.0', true],
['1.1.0-alpha.1', '^1.0.0', true],
['1.2.0', '>1.0.0', true],
['1.2.0', '<1.0.0', false],
['1.0.0', '<=1.0.0', true],
['1.0.0', '<=2.0.0', true],
['1.0.1', '1.0.0', false],
['1.0.0', '1.0.0', true],
['10.1.8', '>10.0.4', true],
['10.1.8', '>=10.0.4', true],
['10.0.1', '=10.0.1', true],
['10.0.1', '=10.1.*', false],
['10.1.1', '<10.2.2', true],
['10.1.1', '<10.0.2', false],
['10.1.1', '<=10.2.2', true],
['10.1.1', '<=10.1.1', true],
['10.1.1', '<=10.0.2', false],
['10.1.1', '>=10.0.2', true],
['10.1.1', '>=10.1.1', true],
['10.1.1', '>=10.2.2', false],
['3', '3.x.x', true],
['3.3', '3.x.x', true],
['3.3.3', '3.x.x', true],
['3.x.x', '3.3.3', true],
['3.3.3', '3.X.X', true],
['3.3.3', '3.3.x', true],
['3.3.3', '3.*.*', true],
['3.3.3', '3.3.*', true],
['3.0.3', '3.0.*', true],
['1.1.0', '1.2.x', false],
['1.1.0', '2.x.x', false],
['2.0.0', '<2.x.x', false],
['2.0.0', '<=2.x.x', true],
['2.0.0', '>2.x.x', false],
].forEach(([v, m, expected]) => {
it(`${v} satisfies ${m}`, () => {
assert.equal(satisfies(v, m), expected);
});
});
});
6 changes: 3 additions & 3 deletions test/sort.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import compareVersions from '../index.mjs';

describe('sort versions', function () {
it('should sort versions', function () {
var versions = [
const versions = [
'1.2.3',
'4.11.6',
'4.2.0',
Expand All @@ -29,13 +29,13 @@ describe('sort versions', function () {
});

it('should sort different digits', function () {
var versions = ['1.0', '1.0.0', '1.0.1'];
const versions = ['1.0', '1.0.0', '1.0.1'];

assert.deepEqual(versions.sort(compareVersions), ['1.0', '1.0.0', '1.0.1']);
});

it('should sort pre-release', function () {
var versions = ['1.0.0', '1.0.1', '1.0.1-gamma', '1.0.1-alpha'];
const versions = ['1.0.0', '1.0.1', '1.0.1-gamma', '1.0.1-alpha'];

assert.deepEqual(versions.sort(compareVersions), [
'1.0.0',
Expand Down

0 comments on commit aad9932

Please sign in to comment.