/**
* Copyright (c) 2012 Cloudsmith Inc. and other contributors, as listed below.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Cloudsmith
*
*/
package org.cloudsmith.geppetto.semver;
import java.io.Serializable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <p>
* This class represents a range of semamtic versions. The range can be inclusive or non-inclusive at both ends. Open
* ended ranges can be created by using an inclusive {@link Version#MIN} as the lower bound or an inclusive
* {@link Version#MAX} as the upper bound.
* </p>
*
* <p>
* A version range can also be created from a string. The string is parsed according to the following rules:
* <ul>
* <li>1.2.3 — A specific version.</li>
* <li>>1.2.3 — Greater than a specific version.</li>
* <li><1.2.3 — Less than a specific version.</li>
* <li>>=1.2.3 — Greater than or equal to a specific version.</li>
* <li><=1.2.3 — Less than or equal to a specific version.</li>
* <li>>=1.0.0 <2.0.0 — Range of versions; both conditions must be satisfied. (This example would match 1.0.1 but
* not 2.0.1)</li>
* <li>1.x — A semantic major version. (This example would match 1.0.1 but not 2.0.1, and is shorthand for >=1.0.0
* <2.0.0-)</li>
* <li>1.2.x — A semantic major & minor version. (This example would match 1.2.3 but not 1.3.0, and is shorthand for
* >=1.2.0 <1.3.0-)</li>
* <li>* — Matches any version</li>
* </ul>
* A range specifier starting with a tilde ~ character is matched against a version in the following fashion:
* <ul>
* <li>The version must be at least as high as the range.</li>
* <li>The version must be less than the next minor revision above the range.
* </ul>
* For example, the following are equivalent:
* <ul>
* <li>~1.2.3 = >=1.2.3 <1.3.0-)</li>
* <li>~1.2 = >=1.2.0 <1.3.0-)</li>
* <li>~1 = >=1.0.0 <1.1.0-)</li>
* </ul>
* </p>
*/
public class VersionRange implements Serializable {
private static enum CompareType {
LESS, LESS_EQUAL, GREATER, GREATER_EQUAL, EQUAL, EQUAL_WITHOUT_OP, DASH, TILDE, MATCH_ALL
}
private static final long serialVersionUID = 1L;
private static final Pattern TILDE_PATTERN = Pattern.compile("^(\\d+)(?:(?:\\.(\\d+))(?:\\.(\\d+))?)?$");
private static final Pattern X_PATTERN = Pattern.compile("^(\\d+)(?:(?:\\.(x|\\d+))(?:\\.x)?)?$");
public static final VersionRange ALL_INCLUSIVE = new VersionRange(
">=" + Version.MIN, Version.MIN, true, Version.MAX, true);
/**
* Returns a range based on the given string. See class documentation
* for details.
*
* @param versionRequirement
* The string form of the version requirement
* @return The created range
*/
public static VersionRange create(String versionRequirement) {
if(versionRequirement == null)
return null;
int[] posHandle = new int[] { 0 };
CompareType compareType = nextCompareType(versionRequirement, posHandle);
if(compareType == null)
// Empty string or just whitespace.
return null;
if(compareType == CompareType.MATCH_ALL) {
if(hasMore(versionRequirement, posHandle))
throw vomit("Unexpected characters after '*'", versionRequirement);
return ALL_INCLUSIVE;
}
boolean minInclude = true;
boolean maxInclude;
Version min, max;
boolean moreAllowed = false;
String version = nextVersion(versionRequirement, posHandle);
switch(compareType) {
case DASH:
throw vomit("Cannot start with a dash", versionRequirement);
case EQUAL:
// Version with pre-release
maxInclude = true;
min = max = createVersion(version, versionRequirement);
break;
case TILDE: {
Matcher m = TILDE_PATTERN.matcher(version);
if(!m.matches())
throw vomit("Not a valid tilde version", versionRequirement);
maxInclude = false;
int major = Integer.parseInt(m.group(1));
String minorStr = m.group(2);
String patchStr = m.group(3);
if(minorStr == null) {
min = Version.create(major, 0, 0);
max = Version.create(major, 1, 0, Version.MIN_PRE_RELEASE);
}
else {
int minor = Integer.parseInt(minorStr);
if(patchStr == null) {
min = Version.create(major, minor, 0);
max = Version.create(major, minor + 1, 0, Version.MIN_PRE_RELEASE);
}
else {
int patch = Integer.parseInt(patchStr);
min = Version.create(major, minor, patch);
max = Version.create(major, minor + 1, 0, Version.MIN_PRE_RELEASE);
}
}
break;
}
case EQUAL_WITHOUT_OP: {
Matcher m = X_PATTERN.matcher(version);
if(!m.matches()) {
maxInclude = true;
min = max = createVersion(version, versionRequirement);
moreAllowed = true; // xxx - yyy range still possible
break;
}
maxInclude = false;
int major = Integer.parseInt(m.group(1));
String minorStr = m.group(2);
if(minorStr == null || "x".equals(minorStr)) {
min = Version.create(major, 0, 0);
max = Version.create(major + 1, 0, 0, Version.MIN_PRE_RELEASE);
}
else {
int minor = Integer.parseInt(minorStr);
min = Version.create(major, minor, 0);
max = Version.create(major, minor + 1, 0, Version.MIN_PRE_RELEASE);
}
break;
}
case LESS:
case LESS_EQUAL:
maxInclude = compareType == CompareType.LESS_EQUAL;
min = Version.MIN;
max = createVersion(version, versionRequirement);
moreAllowed = true;
break;
default: // GREATER or GREATER_EQUAL
minInclude = compareType == CompareType.GREATER_EQUAL;
maxInclude = true;
min = createVersion(version, versionRequirement);
max = Version.MAX;
moreAllowed = true;
break;
}
CompareType compareType2 = nextCompareType(versionRequirement, posHandle);
if(compareType2 != null) {
if(!moreAllowed)
throw new IllegalArgumentException("Unexpected characters after version range");
if(compareType == CompareType.EQUAL_WITHOUT_OP && compareType2 != CompareType.DASH)
// The only token we accept here is the DASH for the 1.0.0 - 2.0.0 form
throw new IllegalArgumentException("Can't create a range where one condition is of type 'equal'");
version = nextVersion(versionRequirement, posHandle);
switch(compareType2) {
case DASH:
if(compareType != CompareType.EQUAL_WITHOUT_OP)
throw new IllegalArgumentException(
"Can't create a dash range unless both sides are without operator");
max = createVersion(version, versionRequirement);
maxInclude = true;
break;
case LESS:
case LESS_EQUAL:
if(compareType == CompareType.LESS || compareType == CompareType.LESS_EQUAL)
throw new IllegalArgumentException("Can't combine two 'less' conditions into a range");
max = createVersion(version, versionRequirement);
maxInclude = compareType2 == CompareType.LESS_EQUAL;
break;
case GREATER:
case GREATER_EQUAL:
if(compareType == CompareType.GREATER || compareType == CompareType.GREATER_EQUAL)
throw new IllegalArgumentException("Can't combine two 'greater' conditions into a range");
min = createVersion(version, versionRequirement);
minInclude = compareType2 == CompareType.GREATER_EQUAL;
break;
default:
throw new IllegalArgumentException("Illegal second operator in range");
}
}
int cmp = min.compareTo(max);
if(!(cmp < 0 || (cmp == 0 && minInclude && maxInclude)))
throw new IllegalArgumentException("lower bound must be less or equal to upper bound");
return new VersionRange(versionRequirement, min, minInclude, max, maxInclude);
}
/**
* Creates a new VersionRange according to detailed specification.
*
* @param lower
* @param lowerBoundInclusive
* @param upper
* @param upperBoundInclusive
* @return
*/
public static VersionRange create(Version lower, boolean lowerBoundInclusive, Version upper,
boolean upperBoundInclusive) {
return new VersionRange(null, lower, lowerBoundInclusive, upper, upperBoundInclusive);
}
private static Version createVersion(String version, String range) {
try {
return Version.create(version);
}
catch(IllegalArgumentException e) {
throw vomit(e.getMessage(), range);
}
}
/**
* Returns a range that will be an exact match for the given version.
*
* @param version
* The version that the range must match
* @return The created range
*/
public static VersionRange exact(Version version) {
return version == null
? null
: new VersionRange(null, version, true, version, true);
}
/**
* Returns a range that will match versions greater than the given version.
*
* @param version
* The version that serves as the non inclusive lower bound
* @return The created range
*/
public static VersionRange greater(Version version) {
return version == null
? null
: new VersionRange(null, version, false, Version.MAX, true);
}
/**
* Returns a range that will match versions greater than or equal the given version.
*
* @param version
* The version that serves as the inclusive lower bound
* @return The created range
*/
public static VersionRange greaterOrEqual(Version version) {
return version == null
? null
: new VersionRange(null, version, true, Version.MAX, true);
}
private static boolean hasMore(String s, int[] posHandle) {
return s.length() >= skipWhite(s, posHandle[0]);
}
/**
* Returns a range that will match versions less than the given version.
*
* @param version
* The version that serves as the non inclusive upper bound
* @return The created range
*/
public static VersionRange less(Version version) {
return version == null
? null
: new VersionRange(null, Version.MIN, true, version, false);
}
/**
* Returns a range that will match versions less than or equal to the given version.
*
* @param version
* The version that serves as the non inclusive upper bound
* @return The created range
*/
public static VersionRange lessOrEqual(Version version) {
return version == null
? null
: new VersionRange(null, Version.MIN, true, version, true);
}
private static CompareType nextCompareType(String s, int[] posHandle) {
int pos = skipWhite(s, posHandle[0]);
int top = s.length();
if(pos >= top)
return null;
CompareType compareType;
char c = s.charAt(pos);
if(c == '>') {
++pos;
if(pos < top && s.charAt(pos) == '=') {
++pos;
compareType = CompareType.GREATER_EQUAL;
}
else
compareType = CompareType.GREATER;
}
else if(c == '<') {
++pos;
if(pos < top && s.charAt(pos) == '=') {
++pos;
compareType = CompareType.LESS_EQUAL;
}
else
compareType = CompareType.LESS;
}
else if(c == '=') {
++pos;
compareType = CompareType.EQUAL;
}
else if(c == '-') {
++pos;
compareType = CompareType.DASH;
}
else if(c == '~') {
++pos;
compareType = CompareType.TILDE;
}
else if(c == '*') {
++pos;
compareType = CompareType.MATCH_ALL;
}
else if(c >= '0' && c <= '9')
compareType = CompareType.EQUAL_WITHOUT_OP;
else
throw new IllegalArgumentException("Expected one of '<', '>' or digit at position " + pos + " in range '" +
s + '\'');
posHandle[0] = pos;
return compareType;
}
private static String nextVersion(String s, int[] posHandle) {
int pos = skipWhite(s, posHandle[0]);
int top = s.length();
int idx = pos;
while(idx < top) {
char c = s.charAt(idx);
if(c == '>' || c == '<' || Character.isWhitespace(c))
break;
++idx;
}
if(idx == pos)
throw new IllegalArgumentException("Expected version at position " + pos + " in range '" + s + '\'');
posHandle[0] = idx;
return s.substring(pos, idx);
}
private static int skipWhite(String s, int pos) {
int top = s.length();
while(pos < top && Character.isWhitespace(s.charAt(pos)))
++pos;
return pos;
}
private static IllegalArgumentException vomit(String reason, String range) {
return new IllegalArgumentException(reason + " in range '" + range + '\'');
}
private final Version minVersion;
private final boolean includeMin;
private final Version maxVersion;
private final boolean includeMax;
private final String originalString;
private VersionRange(String originalString, Version minVersion, boolean includeMin, Version maxVersion,
boolean includeMax) {
this.originalString = originalString;
this.minVersion = minVersion;
this.includeMin = includeMin;
this.maxVersion = maxVersion;
this.includeMax = includeMax;
}
@Override
public boolean equals(Object o) {
if(o instanceof VersionRange) {
VersionRange vr = (VersionRange) o;
return includeMin == vr.includeMin && includeMax == vr.includeMax && minVersion.equals(vr.minVersion) &&
maxVersion.equals(vr.maxVersion);
}
return false;
}
/**
* Scans the provided collection of candidates and returns the highest version
* that is included in this range.
*
* @param candidateVersions
* The collection of candidate versions
* @return The best match or <tt>null</tt> if no match was found
*/
public Version findBestMatch(Iterable<Version> candidateVersions) {
Version best = null;
for(Version candidate : candidateVersions)
if((best == null || candidate.compareTo(best) > 0) && isIncluded(candidate))
best = candidate;
return best;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + maxVersion.hashCode();
result = prime * result + minVersion.hashCode();
result = prime * result + (includeMax
? 1231
: 1237);
result = prime * result + (includeMin
? 1231
: 1237);
return result;
}
public VersionRange intersect(VersionRange r2) {
int minCompare = minVersion.compareTo(r2.minVersion);
int maxCompare = maxVersion.compareTo(r2.maxVersion);
boolean resultMinIncluded;
Version resultMin;
if(minCompare == 0) {
if(maxCompare == 0 && includeMin == r2.includeMin && includeMax == r2.includeMax)
return this;
resultMin = minVersion;
resultMinIncluded = includeMin && r2.includeMin;
}
else if(minCompare < 0) {
resultMin = r2.minVersion;
resultMinIncluded = r2.includeMin;
}
else { // minCompare > 0)
resultMin = minVersion;
resultMinIncluded = includeMin;
}
boolean resultMaxIncluded;
Version resultMax;
if(maxCompare > 0) {
resultMax = r2.maxVersion;
resultMaxIncluded = r2.includeMax;
}
else if(maxCompare < 0) {
resultMax = maxVersion;
resultMaxIncluded = includeMax;
}
else {// maxCompare == 0
resultMax = maxVersion;
resultMaxIncluded = includeMax && r2.includeMax;
}
int minMaxCmp = resultMin.compareTo(resultMax);
if(minMaxCmp < 0 || (minMaxCmp == 0 && resultMinIncluded && resultMaxIncluded))
return new VersionRange(null, resultMin, resultMinIncluded, resultMax, resultMaxIncluded);
return null;
}
/**
* Compares the given range with this range and returns true
* if this requirement is equally or more restrictive in appointing a range
* of versions. More restrictive means that the appointed range equal or
* smaller and completely within the range appointed by the other version.
*
* @param vr
* The requirement to compare with
* @return <tt>true</tt> if this requirement is as restrictive as the argument
*/
public boolean isAsRestrictiveAs(VersionRange vr) {
int cmp = vr.minVersion.compareTo(minVersion);
if(cmp > 0 || (cmp == 0 && includeMin && !vr.includeMin))
return false;
cmp = vr.maxVersion.compareTo(maxVersion);
if(cmp < 0 || (cmp == 0 && includeMax && !vr.includeMax))
return false;
return true;
}
/**
* Checks if <tt>version</tt> is included in the range described by this instance.
*
* @param version
* the version to test.
* @return <tt>true</tt> if the version is include. <tt>false</tt> if the version
* was <tt>null</tt> or not included in this range.
*/
public boolean isIncluded(Version version) {
if(version == null)
return false;
if(minVersion == maxVersion)
// Can only happen when both includeMin and includeMax are true
return minVersion.equals(version);
int minCheck = includeMin
? 0
: -1;
int maxCheck = includeMax
? 0
: 1;
return minVersion.compareTo(version) <= minCheck && maxVersion.compareTo(version) >= maxCheck;
}
@Override
public String toString() {
StringBuilder bld = new StringBuilder();
toString(bld);
return bld.toString();
}
public void toString(StringBuilder bld) {
if(originalString != null)
bld.append(originalString);
else {
if(includeMin && includeMax && minVersion.equals(maxVersion))
// Perfect match
minVersion.toString(bld);
else {
boolean needSpace = false;
if(!minVersion.equals(Version.MIN)) {
bld.append('>');
if(includeMin)
bld.append('=');
minVersion.toString(bld);
needSpace = true;
}
if(!maxVersion.equals(Version.MAX)) {
if(needSpace)
bld.append(' ');
bld.append('<');
if(includeMax)
bld.append('=');
maxVersion.toString(bld);
}
}
}
}
}