Skip to content

Commit

Permalink
Merge pull request #330 from gastaldi/range
Browse files Browse the repository at this point in the history
Introduce `VersionRange`
  • Loading branch information
dmlloyd authored Jul 23, 2024
2 parents a477af0 + 64a83fe commit c29bb1c
Show file tree
Hide file tree
Showing 4 changed files with 470 additions and 1 deletion.
9 changes: 8 additions & 1 deletion version/pom.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
Expand Down Expand Up @@ -39,6 +40,12 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>

<!-- so we can compare our version comparison algorithm against Maven's -->
<dependency>
<groupId>org.apache.maven.resolver</groupId>
Expand Down
22 changes: 22 additions & 0 deletions version/src/main/java/io/smallrye/common/version/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,26 @@ interface Messages {

@Message(id = 3010, value = "Build string may not be empty")
VersionSyntaxException emptyBuild();

@Message(id = 3011, value = "Unbounded range: %s")
IllegalArgumentException unboundedRange(String pattern);

// 3012

// 3013

@Message(id = 3014, value = "Single version must be surrounded by []: %s")
IllegalArgumentException singleVersionMustBeSurroundedByBrackets(String version);

@Message(id = 3015, value = "Range defies version ordering: %s")
IllegalArgumentException rangeDefiesVersionOrdering(String version);

@Message(id = 3016, value = "Unexpected version range character: %s")
IllegalArgumentException rangeUnexpected(String version);

@Message(id = 3017, value = "Standalone version cannot have an upper bound")
IllegalArgumentException standaloneVersionCannotBeBound();

@Message(id = 3018, value = "Inclusive versions cannot be empty")
IllegalArgumentException inclusiveVersionCannotBeEmpty();
}
329 changes: 329 additions & 0 deletions version/src/main/java/io/smallrye/common/version/VersionScheme.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.smallrye.common.version;

import java.util.Comparator;
import java.util.Objects;
import java.util.function.Predicate;

/**
* A versioning scheme, which has distinct sorting, iteration, and canonicalization rules.
Expand All @@ -17,6 +19,181 @@ public interface VersionScheme extends Comparator<String> {
*/
int compare(String v1, String v2);

/**
* Determine if the first version is less than the second version according to this version scheme.
*
* @param base the base version
* @param other the other version
* @return {@code true} if the first version is less than the second version, or {@code false} otherwise
*/
default boolean lt(String base, String other) {
return compare(base, other) < 0;
}

/**
* Determine if the first version is less than or equal to the second version according to this version scheme.
*
* @param base the base version
* @param other the other version
* @return {@code true} if the first version is less than or equal to the second version, or {@code false} otherwise
*/
default boolean le(String base, String other) {
return compare(base, other) <= 0;
}

/**
* Determine if the first version is greater than or equal to the second version according to this version scheme.
*
* @param base the base version
* @param other the other version
* @return {@code true} if the first version is greater than or equal to the second version, or {@code false} otherwise
*/
default boolean gt(String base, String other) {
return compare(base, other) > 0;
}

/**
* Determine if the first version is greater than the second version according to this version scheme.
*
* @param base the base version
* @param other the other version
* @return {@code true} if the first version is greater than the second version, or {@code false} otherwise
*/
default boolean ge(String base, String other) {
return compare(base, other) >= 0;
}

/**
* {@return the lesser (earlier) of the two versions}
*
* @param a the first version (must not be {@code null})
* @param b the second version (must not be {@code null})
*/
default String min(String a, String b) {
return le(a, b) ? a : b;
}

/**
* {@return the greater (later) of the two versions}
*
* @param a the first version (must not be {@code null})
* @param b the second version (must not be {@code null})
*/
default String max(String a, String b) {
return ge(a, b) ? a : b;
}

/**
* Returns a predicate that tests if the version is equal to the base version.
*
* @param other the other version
* @return {@code true} if the first version is equal to the second version, or {@code false} otherwise
*/
default Predicate<String> whenEquals(String other) {
return base -> equals(base, other);
}

/**
* Returns a predicate that tests if the version is greater than or equal to the base version.
*
* @param other the other version
* @return {@code true} if the first version is less than the second version, or {@code false} otherwise
*/
default Predicate<String> whenGt(String other) {
return base -> gt(base, other);
}

/**
* Returns a predicate that tests if the version is greater than or equal to the base version.
*
* @param other the other version
* @return a predicate that tests if the version is greater than or equal to the base version
*/
default Predicate<String> whenGe(String other) {
return base -> ge(base, other);
}

/**
* Returns a predicate that tests if the version is less than or equal to the base version.
*
* @param other the other version
* @return a predicate that tests if the version is less than or equal to the base version
*/
default Predicate<String> whenLe(String other) {
return base -> le(base, other);
}

/**
* Returns a predicate that tests if the version is less than the base version.
*
* @param other the other version
* @return a predicate that tests if the version is less than the base version
*/
default Predicate<String> whenLt(String other) {
return base -> lt(base, other);
}

/**
* Parse a range specification and return it as a predicate.
* This method behaves as a call to {@link #fromRangeString(String, int, int) fromRangeString(range, 0, range.length())}.
*
* @param range the range string to parse (must not be {@code null})
* @return the parsed range (not {@code null})
* @throws IllegalArgumentException if there is a syntax error in the range or the range cannot match any version
*/
default Predicate<String> fromRangeString(String range) {
return fromRangeString(range, 0, range.length());
}

// @formatter:off
/**
* Parse a range specification and return it as a predicate.
* Version ranges are governed by the following general syntax:
* <code><pre>
range ::= range-spec ',' range
| range-spec
range-spec ::= '[' version ']
| min-version ',' max-version
min-version ::= '[' version
| '(' version
| '('
max-version ::= version ']'
| version ')'
| ')'
</pre></code>
* This is aligned with the syntax used by Maven, however it can be applied to any
* supported version scheme.
* <p>
* It is important to note that within a range specification, the {@code ,} separator
* indicates a logical "and" or "intersection" operation, whereas the {@code ,} separator
* found in between range specifications acts as a logical "or" or "union" operation.
* <p>
* Here are some examples of valid version range specifications:
* <ul>
* <li><code>1.0</code> Version 1.0 as a recommended version (like {@code whenEquals("1.0")})</li>
* <li><code>[1.0]</code> Version 1.0 explicitly only (like {@code whenEquals("1.0")})</li>
* <li><code>[1.0,2.0)</code> Versions 1.0 (included) to 2.0 (not included) (like {@code whenGe("1.0").and(whenLt("2.0"))})</li>
* <li><code>[1.0,2.0]</code> Versions 1.0 to 2.0 (both included) (like {@code whenGe("1.0").and(whenLe("2.0"))})</li>
* <li><code>[1.5,)</code> Versions 1.5 and higher (like {@code whenGe("1.5")})</li>
* <li><code>(,1.0],[1.2,)</code> Versions up to 1.0 (included) and 1.2 or higher (like {@code whenLe("1.0").or(whenGe("1.2"))})</li>
* </ul>
*
* @param range the range string to parse (must not be {@code null})
* @param start the start of the range within the string (inclusive)
* @param end the end of the range within the string (exclusive)
* @return the parsed range (not {@code null})
* @throws IllegalArgumentException if there is a syntax error in the range or the range cannot match any version
* @throws IndexOutOfBoundsException if the values for {@code start} or {@code end} are not valid
*/
// @formatter:on
default Predicate<String> fromRangeString(String range, int start, int end) {
Objects.checkFromToIndex(start, end, range.length());
return parseRange(range, start, end);
}

/**
* Determine if two versions are equal according to this version scheme.
*
Expand Down Expand Up @@ -131,4 +308,156 @@ default void validate(String version) throws VersionSyntaxException {
* This versioning scheme is based approximately on semantic versioning but with a few differences.
*/
VersionScheme JPMS = new JpmsVersionScheme();

private Predicate<String> parseRange(final String range, int start, final int end) {
if (start == end) {
return whenEquals("");
}
int cp = range.codePointAt(start);
int cnt = Character.charCount(cp);
switch (cp) {
case '[': {
return parseMinIncl(range, start + cnt, end);
}
case '(': {
return parseMinExcl(range, start + cnt, end);
}
case ',': {
return parseMore(whenEquals(""), range, start + cnt, end);
}
default: {
return parseSingle(range, start + cnt, end);
}
}
}

private Predicate<String> parseSingle(String range, int start, int end) {
int i = start;
int cp, cnt;
do {
cp = range.codePointAt(i);
cnt = Character.charCount(cp);
switch (cp) {
case ',': {
return parseMore(whenEquals(range.substring(start, i)), range, i + cnt, end);
}
case ']':
case ')': {
throw Messages.msg.standaloneVersionCannotBeBound();
}
}
i += cnt;
} while (i < end);
// just a single version
return whenEquals(range.substring(start, end));
}

private Predicate<String> parseMinIncl(String range, int start, int end) {
int i = start;
int cp;
do {
cp = range.codePointAt(i);
int cnt = Character.charCount(cp);
switch (cp) {
case ',': {
if (i == start) {
throw Messages.msg.inclusiveVersionCannotBeEmpty();
}
return parseRangeMax(whenGe(range.substring(start, i)), range, i + cnt, end);
}
case ']': {
return parseMore(whenEquals(range.substring(start, i)), range, i + cnt, end);
}
case ')': {
throw Messages.msg.singleVersionMustBeSurroundedByBrackets(range.substring(start, i + cnt));
}
}
i += cnt;
} while (i < end);
// ended short, so treat it as open-ended
return whenGe(range.substring(start, end));
}

private Predicate<String> parseMinExcl(String range, int start, int end) {
int i = start;
int cp;
do {
cp = range.codePointAt(i);
int cnt = Character.charCount(cp);
switch (cp) {
case ',': {
if (i == start) {
// include all
return parseRangeMax(null, range, i + cnt, end);
} else {
return parseRangeMax(whenGt(range.substring(start, i)), range, i + cnt, end);
}
}
case ']':
case ')': {
throw Messages.msg.singleVersionMustBeSurroundedByBrackets(range.substring(start, i + cnt));
}
}
i += cnt;
} while (i < end);
// ended short, so treat it as open-ended
return whenGt(range.substring(start, end));
}

private Predicate<String> parseRangeMax(Predicate<String> min, String range, int start, int end) {
int i = start;
int cp;
do {
cp = range.codePointAt(i);
int cnt = Character.charCount(cp);
switch (cp) {
case ')': {
if (i == start) {
// empty upper range; only consider the minimum range
return parseMore(min, range, i + cnt, end);
}
// fall through
}
case ']': {
String high = range.substring(start, i);
if (min != null && !min.test(high)) {
// low end must be higher than high end
throw Messages.msg.rangeDefiesVersionOrdering(range.substring(start, i + cnt));
}
Predicate<String> max = cp == ']' ? whenLe(high) : whenLt(high);
return parseMore(min == null ? max : min.and(max), range, i + cnt, end);
}
case ',': {
throw Messages.msg.rangeUnexpected(range.substring(start, i + cnt));
}
}
i += cnt;
} while (i < end);
// ended short
throw Messages.msg.unboundedRange(range.substring(start, end));
}

/**
* Parse the end context (make sure there is no trailing garbage, combine subsequent predicates).
*
* @param predicate the predicate to return
* @param range the range string
* @param start the remaining start
* @param end the end
* @return the predicate
*/
private Predicate<String> parseMore(Predicate<String> predicate, final String range, int start, int end) {
if (start < end) {
int cp = range.codePointAt(start);
int cnt = Character.charCount(cp);
if (cp == ',') {
// composed version ranges
Predicate<String> nextRange = parseRange(range, start + cnt, end);
return predicate == null ? nextRange : predicate.or(nextRange);
}
throw Messages.msg.rangeUnexpected(range.substring(start, start + cnt));
} else {
return predicate;
}
}
}
Loading

0 comments on commit c29bb1c

Please sign in to comment.