Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce VersionRange #330

Merged
merged 9 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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