From 94cdf2cd3ea41de1bd0a9e41de602b5b25ed5e7f Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Fri, 8 Jun 2018 08:44:16 +0200
Subject: [PATCH] feat: composer range support (#2099)

Adds range support for composer. Mostly leverages existing npm semver range support, but massages where necessary to support Composer differences.

Closes #2097
---
 lib/manager/composer/extract.js               |   9 +-
 lib/versioning/index.js                       |   2 +
 lib/versioning/semver-composer/index.js       |  66 ++++++++++++
 .../__snapshots__/extract.spec.js.snap        |  81 +++++++-------
 test/versioning/semver-composer.spec.js       | 102 ++++++++++++++++++
 5 files changed, 215 insertions(+), 45 deletions(-)
 create mode 100644 lib/versioning/semver-composer/index.js
 create mode 100644 test/versioning/semver-composer.spec.js

diff --git a/lib/manager/composer/extract.js b/lib/manager/composer/extract.js
index aa54acefa2..7b0fc76580 100644
--- a/lib/manager/composer/extract.js
+++ b/lib/manager/composer/extract.js
@@ -1,4 +1,4 @@
-const semver = require('../../versioning')('semver');
+const semverComposer = require('../../versioning')('semverComposer');
 
 module.exports = {
   extractDependencies,
@@ -24,15 +24,18 @@ function extractDependencies(content, packageFile) {
             depType,
             depName,
             currentValue,
-            verionScheme: 'semver',
+            versionScheme: 'semverComposer',
             purl: 'pkg:packagist/' + depName,
           };
           if (!depName.includes('/')) {
             dep.skipReason = 'unsupported';
           }
-          if (!semver.isVersion(currentValue)) {
+          if (!semverComposer.isValid(currentValue)) {
             dep.skipReason = 'unsupported-constraint';
           }
+          if (currentValue === '*') {
+            dep.skipReason = 'any-version';
+          }
           deps.push(dep);
         }
       } catch (err) /* istanbul ignore next */ {
diff --git a/lib/versioning/index.js b/lib/versioning/index.js
index e179235b12..7cad3bb8c0 100644
--- a/lib/versioning/index.js
+++ b/lib/versioning/index.js
@@ -1,8 +1,10 @@
 const semver = require('./semver');
+const semverComposer = require('./semver-composer');
 const pep440 = require('./pep440');
 
 const schemes = {
   semver,
+  semverComposer,
   pep440,
 };
 
diff --git a/lib/versioning/semver-composer/index.js b/lib/versioning/semver-composer/index.js
new file mode 100644
index 0000000000..0148e9f0b2
--- /dev/null
+++ b/lib/versioning/semver-composer/index.js
@@ -0,0 +1,66 @@
+const semver = require('../semver');
+
+function composer2npm(input) {
+  if (semver.isVersion(input)) {
+    return input;
+  }
+  let output = input;
+  // ~4 to ^4 and ~4.1 to ^4.1
+  output = output.replace(/(?:^|\s)~([1-9][0-9]*(?:\.[0-9]*)?)(?: |$)/g, '^$1');
+  // ~0.4 to >=0.4 <1
+  output = output.replace(/(?:^|\s)~(0\.[1-9][0-9]*)(?: |$)/g, '>=$1 <1');
+  return output;
+}
+
+const isLessThanRange = (version, range) =>
+  semver.isLessThanRange(version, composer2npm(range));
+
+const isValid = input => semver.isValid(composer2npm(input));
+
+const matches = (version, range) =>
+  semver.matches(version, composer2npm(range));
+
+const maxSatisfyingVersion = (versions, range) =>
+  semver.maxSatisfyingVersion(versions, composer2npm(range));
+
+const minSatisfyingVersion = (versions, range) =>
+  semver.minSatisfyingVersion(versions, composer2npm(range));
+
+function getNewValue(config, fromVersion, toVersion) {
+  const { currentValue } = config;
+  if (
+    semver.isValid(currentValue) &&
+    composer2npm(currentValue) === currentValue
+  ) {
+    return semver.getNewValue(config, fromVersion, toVersion);
+  }
+  const toMajor = semver.getMajor(toVersion);
+  const toMinor = semver.getMinor(toVersion);
+  // handle ~0.4 case first
+  if (currentValue.match(/^~(0\.[1-9][0-9]*)$/)) {
+    if (toMajor === 0) {
+      return `~0.${toMinor}`;
+    }
+    return `~${toMajor}.0`;
+  }
+  // handle ~4 case
+  if (currentValue.match(/^~([0-9]*)$/)) {
+    return `~${toMajor}`;
+  }
+  // handle ~4.1 case
+  if (currentValue.match(/^~([0-9]*(?:\.[0-9]*)?)$/)) {
+    return `~${toMajor}.${toMinor}`;
+  }
+  logger.warn('Unsupported composer selector');
+  return toVersion;
+}
+
+module.exports = {
+  ...semver,
+  isLessThanRange,
+  isValid,
+  matches,
+  maxSatisfyingVersion,
+  minSatisfyingVersion,
+  getNewValue,
+};
diff --git a/test/manager/composer/__snapshots__/extract.spec.js.snap b/test/manager/composer/__snapshots__/extract.spec.js.snap
index afa89b446d..0ca7e75d76 100644
--- a/test/manager/composer/__snapshots__/extract.spec.js.snap
+++ b/test/manager/composer/__snapshots__/extract.spec.js.snap
@@ -8,8 +8,8 @@ Object {
       "depName": "php",
       "depType": "require",
       "purl": "pkg:packagist/php",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "unsupported",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -17,7 +17,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/symfony/assetic-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -25,7 +25,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/symfony/monolog-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -33,22 +33,21 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/symfony/swiftmailer-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "2.1.*",
       "depName": "symfony/symfony",
       "depType": "require",
       "purl": "pkg:packagist/symfony/symfony",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "2.2.2",
       "depName": "doctrine/common",
       "depType": "require",
       "purl": "pkg:packagist/doctrine/common",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -56,7 +55,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/doctrine/doctrine-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -64,15 +63,14 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/doctrine/doctrine-fixtures-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "2.2.x-dev",
       "depName": "doctrine/orm",
       "depType": "require",
       "purl": "pkg:packagist/doctrine/orm",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -80,7 +78,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/exercise/elastica-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -88,45 +86,45 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/friendsofsymfony/rest-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "friendsofsymfony/user-bundle",
       "depType": "require",
       "purl": "pkg:packagist/friendsofsymfony/user-bundle",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "fzaninotto/faker",
       "depType": "require",
       "purl": "pkg:packagist/fzaninotto/faker",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "1.0.1",
       "depName": "jms/di-extra-bundle",
       "depType": "require",
       "purl": "pkg:packagist/jms/di-extra-bundle",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "jms/payment-core-bundle",
       "depType": "require",
       "purl": "pkg:packagist/jms/payment-core-bundle",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "1.1.0",
       "depName": "jms/security-extra-bundle",
       "depType": "require",
       "purl": "pkg:packagist/jms/security-extra-bundle",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -134,7 +132,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/knplabs/knp-menu-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -142,7 +140,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/knplabs/knp-paginator-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -150,7 +148,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/liip/imagine-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -158,7 +156,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/merk/dough-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -166,7 +164,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/sensio/distribution-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -174,7 +172,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/sensio/framework-extra-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -182,7 +180,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/sensio/generator-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -190,7 +188,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/simplethings/entity-audit-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -198,7 +196,7 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/stof/doctrine-extensions-bundle",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "dev-master",
@@ -206,47 +204,46 @@ Object {
       "depType": "require",
       "purl": "pkg:packagist/twig/extensions",
       "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "2.3.*",
       "depName": "behat/behat",
       "depType": "require-dev",
       "purl": "pkg:packagist/behat/behat",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "behat/behat-bundle",
       "depType": "require-dev",
       "purl": "pkg:packagist/behat/behat-bundle",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "behat/mink-bundle",
       "depType": "require-dev",
       "purl": "pkg:packagist/behat/mink-bundle",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "behat/sahi-client",
       "depType": "require-dev",
       "purl": "pkg:packagist/behat/sahi-client",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
     Object {
       "currentValue": "*",
       "depName": "behat/common-contexts",
       "depType": "require-dev",
       "purl": "pkg:packagist/behat/common-contexts",
-      "skipReason": "unsupported-constraint",
-      "verionScheme": "semver",
+      "skipReason": "any-version",
+      "versionScheme": "semverComposer",
     },
   ],
 }
diff --git a/test/versioning/semver-composer.spec.js b/test/versioning/semver-composer.spec.js
new file mode 100644
index 0000000000..7cd77f7195
--- /dev/null
+++ b/test/versioning/semver-composer.spec.js
@@ -0,0 +1,102 @@
+const semver = require('../../lib/versioning')('semverComposer');
+
+describe('semver.isValid(input)', () => {
+  it('should support simple semver', () => {
+    expect(!!semver.isValid('1.2.3')).toBe(true);
+  });
+  it('should support semver with dash', () => {
+    expect(!!semver.isValid('1.2.3-foo')).toBe(true);
+  });
+  it('should reject semver without dash', () => {
+    expect(!!semver.isValid('1.2.3foo')).toBe(false);
+  });
+  it('should support ranges', () => {
+    expect(!!semver.isValid('~1.2.3')).toBe(true);
+    expect(!!semver.isValid('^1.2.3')).toBe(true);
+    expect(!!semver.isValid('>1.2.3')).toBe(true);
+  });
+});
+describe('semver.isLessThanRange()', () => {
+  it('handles massaged tilde', () => {
+    expect(semver.isLessThanRange('0.3.1', '~0.4')).toBe(true);
+    expect(semver.isLessThanRange('0.5.1', '~0.4')).toBe(false);
+  });
+});
+describe('semver.maxSatisfyingVersion()', () => {
+  it('handles massaged tilde', () => {
+    expect(
+      semver.maxSatisfyingVersion(
+        ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
+        '~4'
+      )
+    ).toBe('4.2.0');
+    expect(
+      semver.maxSatisfyingVersion(
+        ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
+        '~0.4'
+      )
+    ).toBe('0.5.0');
+  });
+});
+describe('semver.minSatisfyingVersion()', () => {
+  it('handles massaged tilde', () => {
+    expect(
+      semver.minSatisfyingVersion(
+        ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
+        '~4'
+      )
+    ).toBe('4.0.0');
+    expect(
+      semver.minSatisfyingVersion(
+        ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'],
+        '~0.4'
+      )
+    ).toBe('0.4.0');
+  });
+});
+describe('semver.matches()', () => {
+  it('handles massaged tilde', () => {
+    expect(semver.matches('0.3.1', '~0.4')).toBe(false);
+    expect(semver.matches('0.5.1', '~0.4')).toBe(true);
+  });
+});
+describe('semver.getNewValue()', () => {
+  it('bumps short caret to same', () => {
+    expect(
+      semver.getNewValue(
+        { currentValue: '^1.0', rangeStrategy: 'bump' },
+        '1.0.0',
+        '1.0.7'
+      )
+    ).toEqual('^1.0');
+  });
+  it('handles tilde zero', () => {
+    expect(
+      semver.getNewValue({ currentValue: '~0.2' }, '0.2.0', '0.3.0')
+    ).toEqual('~0.3');
+    expect(
+      semver.getNewValue({ currentValue: '~0.2' }, '0.2.0', '1.1.0')
+    ).toEqual('~1.0');
+  });
+  it('handles tilde major', () => {
+    expect(
+      semver.getNewValue({ currentValue: '~4' }, '4.0.0', '4.2.0')
+    ).toEqual('~4');
+    expect(
+      semver.getNewValue({ currentValue: '~4' }, '4.0.0', '5.1.0')
+    ).toEqual('~5');
+  });
+  it('handles tilde minor', () => {
+    expect(
+      semver.getNewValue({ currentValue: '~4.0' }, '4.0.0', '4.2.0')
+    ).toEqual('~4.2');
+    expect(
+      semver.getNewValue({ currentValue: '~4.0' }, '4.0.0', '5.1.0')
+    ).toEqual('~5.1');
+  });
+  it('returns toVersion if unsupported', () => {
+    expect(
+      semver.getNewValue({ currentValue: '+4.0.0' }, '4.0.0', '4.2.0')
+    ).toEqual('4.2.0');
+  });
+});
-- 
GitLab