/**
* Copyright (C) 2022, 2024, 2025 Adrien Hopkins
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package sevenUnits.utils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* A version number in the Semantic Versioning
* scheme
*
{
/**
* A builder that can be used to create complex version numbers.
*
* Note: None of this builder's methods tolerate null arguments, arrays
* containing nulls, negative numbers, or non-alphanumeric identifiers. Nulls
* throw NullPointerExceptions, everything else throws
* IllegalArgumentException.
*
* @since 2022-02-19
* @since v0.4.0
*/
public static final class Builder {
private final int major;
private final int minor;
private final int patch;
private final List preReleaseIdentifiers;
private final List buildMetadata;
/**
* Creates a builder which can be used to create a
* {@code SemanticVersionNumber}
*
* @param major major version number of final version
* @param minor minor version number of final version
* @param patch patch version number of final version
* @since 2022-02-19
* @since v0.4.0
*/
private Builder(int major, int minor, int patch) {
this.major = major;
this.minor = minor;
this.patch = patch;
this.preReleaseIdentifiers = new ArrayList<>();
this.buildMetadata = new ArrayList<>();
}
/**
* @return version number created by this builder
* @since 2022-02-19
* @since v0.4.0
*/
public SemanticVersionNumber build() {
return new SemanticVersionNumber(this.major, this.minor, this.patch,
this.preReleaseIdentifiers, this.buildMetadata);
}
/**
* Adds one or more build metadata identifiers
*
* @param identifiers build metadata
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder buildMetadata(List identifiers) {
Objects.requireNonNull(identifiers, "identifiers may not be null");
for (final String identifier : identifiers) {
Objects.requireNonNull(identifier, "identifier may not be null");
if (!VALID_IDENTIFIER.matcher(identifier).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\"", identifier));
this.buildMetadata.add(identifier);
}
return this;
}
/**
* Adds one or more build metadata identifiers
*
* @param identifiers build metadata
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder buildMetadata(String... identifiers) {
Objects.requireNonNull(identifiers, "identifiers may not be null");
for (final String identifier : identifiers) {
Objects.requireNonNull(identifier, "identifier may not be null");
if (!VALID_IDENTIFIER.matcher(identifier).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\"", identifier));
this.buildMetadata.add(identifier);
}
return this;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof Builder))
return false;
final var other = (Builder) obj;
return Objects.equals(this.buildMetadata, other.buildMetadata)
&& this.major == other.major && this.minor == other.minor
&& this.patch == other.patch && Objects.equals(
this.preReleaseIdentifiers, other.preReleaseIdentifiers);
}
@Override
public int hashCode() {
return Objects.hash(this.buildMetadata, this.major, this.minor,
this.patch, this.preReleaseIdentifiers);
}
/**
* Adds one or more numeric identifiers to the version number
*
* @param identifiers pre-release identifier(s) to add
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder preRelease(int... identifiers) {
Objects.requireNonNull(identifiers, "identifiers may not be null");
for (final int identifier : identifiers) {
if (identifier < 0)
throw new IllegalArgumentException(
"Numeric identifiers may not be negative");
this.preReleaseIdentifiers.add(Integer.toString(identifier));
}
return this;
}
/**
* Adds one or more pre-release identifier(s) to the version number
*
* @param identifiers pre-release identifier(s) to add
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder preRelease(List identifiers) {
Objects.requireNonNull(identifiers, "identifiers may not be null");
for (final String identifier : identifiers) {
Objects.requireNonNull(identifier, "identifier may not be null");
if (!VALID_IDENTIFIER.matcher(identifier).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\"", identifier));
this.preReleaseIdentifiers.add(identifier);
}
return this;
}
/**
* Adds one or more pre-release identifier(s) to the version number
*
* @param identifiers pre-release identifier(s) to add
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder preRelease(String... identifiers) {
Objects.requireNonNull(identifiers, "identifiers may not be null");
for (final String identifier : identifiers) {
Objects.requireNonNull(identifier, "identifier may not be null");
if (!VALID_IDENTIFIER.matcher(identifier).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\"", identifier));
this.preReleaseIdentifiers.add(identifier);
}
return this;
}
/**
* Adds a string identifier and an integer identifer to pre-release data
*
* @param identifier1 first identifier
* @param identifier2 second identifier
* @return this builder
* @since 2022-02-19
* @since v0.4.0
*/
public Builder preRelease(String identifier1, int identifier2) {
Objects.requireNonNull(identifier1, "identifier1 may not be null");
if (!VALID_IDENTIFIER.matcher(identifier1).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\"", identifier1));
if (identifier2 < 0)
throw new IllegalArgumentException(
"Integer identifier cannot be negative");
this.preReleaseIdentifiers.add(identifier1);
this.preReleaseIdentifiers.add(Integer.toString(identifier2));
return this;
}
@Override
public String toString() {
return "Semantic Version Builder: " + this.build().toString();
}
}
/**
* An alternative comparison method for version numbers. This uses the
* version's natural order, but the build metadata will be compared (using
* the same rules as pre-release identifiers) if everything else is equal.
*
* This ordering is consistent with equals, unlike
* {@code SemanticVersionNumber}'s natural ordering.
*/
public static final Comparator BUILD_METADATA_COMPARATOR = new Comparator<>() {
@Override
public int compare(SemanticVersionNumber o1, SemanticVersionNumber o2) {
Objects.requireNonNull(o1, "o1 may not be null");
Objects.requireNonNull(o2, "o2 may not be null");
final var naturalComparison = o1.compareTo(o2);
if (naturalComparison == 0)
return SemanticVersionNumber.compareIdentifiers(o1.buildMetadata,
o2.buildMetadata);
return naturalComparison;
};
};
/** The alphanumeric pattern all identifiers must follow */
private static final Pattern VALID_IDENTIFIER = Pattern
.compile("[0-9A-Za-z-]+");
/** The numeric pattern which causes special behaviour */
private static final Pattern NUMERIC_IDENTIFER = Pattern.compile("[0-9]+");
/** The pattern for a version number */
private static final Pattern VERSION_NUMBER = Pattern
.compile("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)" // main
// version
+ "(?:-([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?" // pre-release
+ "(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?"); // build data
/**
* Creates a builder that can be used to create a version number
*
* @param major major version number of final version
* @param minor minor version number of final version
* @param patch patch version number of final version
* @return version number builder
* @throws IllegalArgumentException if any argument is negative
* @since 2022-02-19
* @since v0.4.0
*/
public static SemanticVersionNumber.Builder builder(int major, int minor,
int patch) {
if (major < 0)
throw new IllegalArgumentException(
"Major version must be non-negative.");
if (minor < 0)
throw new IllegalArgumentException(
"Minor version must be non-negative.");
if (patch < 0)
throw new IllegalArgumentException(
"Patch version must be non-negative.");
return new SemanticVersionNumber.Builder(major, minor, patch);
}
/**
* Compares two lists of strings based on SemVer's precedence rules
*
* @param a first list
* @param b second list
* @return result of comparison as in a comparator
* @see Comparator
* @since 2022-02-20
* @since v0.4.0
*/
private static int compareIdentifiers(List a, List b) {
// test pre-release size
final var aSize = a.size();
final var bSize = b.size();
// no identifiers is greater than any identifiers
if (aSize != 0 && bSize == 0)
return -1;
if (aSize == 0 && bSize != 0)
return 1;
// test identifiers one by one
for (var i = 0; i < Math.min(aSize, bSize); i++) {
final var aElement = a.get(i);
final var bElement = b.get(i);
if (NUMERIC_IDENTIFER.matcher(aElement).matches()) {
if (!NUMERIC_IDENTIFER.matcher(bElement).matches())
// aElement is a number and bElement is not a number
// by the rules, a goes before b
return -1;
// both are numbers, compare them
final var aNumber = Integer.parseInt(aElement);
final var bNumber = Integer.parseInt(bElement);
if (aNumber < bNumber)
return -1;
if (aNumber > bNumber)
return 1;
} else if (NUMERIC_IDENTIFER.matcher(bElement).matches())
// aElement is not a number but bElement is
// by the rules, a goes after b
return 1;
else {
// both are not numbers, compare them
final var comparison = aElement.compareTo(bElement);
if (comparison != 0)
return comparison;
}
}
if (aSize < bSize)
return -1;
if (aSize > bSize)
return 1;
return 0;
// we just tested the stuff that's in common, maybe someone has more
}
/**
* Gets a version number from a string in the official format
*
* @param versionString string to parse
* @return {@code SemanticVersionNumber} instance
* @since 2022-02-19
* @since v0.4.0
* @see #toString
*/
public static SemanticVersionNumber fromString(String versionString) {
// parse & validate version string
Objects.requireNonNull(versionString, "versionString may not be null");
final var m = VERSION_NUMBER.matcher(versionString);
if (!m.matches())
throw new IllegalArgumentException(
String.format("Provided string \"%s\" is not a version number",
versionString));
// main parts
final var major = Integer.parseInt(m.group(1));
final var minor = Integer.parseInt(m.group(2));
final var patch = Integer.parseInt(m.group(3));
// pre release
final List preRelease;
if (m.group(4) == null) {
preRelease = List.of();
} else {
preRelease = Arrays.asList(m.group(4).split("\\."));
}
// build metadata
final List buildMetadata;
if (m.group(5) == null) {
buildMetadata = List.of();
} else {
buildMetadata = Arrays.asList(m.group(5).split("\\."));
}
// return number
return new SemanticVersionNumber(major, minor, patch, preRelease,
buildMetadata);
}
/**
* Tests whether a string is a valid Semantic Version string
*
* @param versionString string to test
* @return true iff string is valid
* @since 2022-02-19
* @since v0.4.0
*/
public static boolean isValidVersionString(String versionString) {
return VERSION_NUMBER.matcher(versionString).matches();
}
/**
* Creates a simple pre-release version number of the form
* MAJOR.MINOR.PATH-TYPE.NUMBER (e.g. 1.2.3-alpha.4).
*
* @param major major version number
* @param minor minor version number
* @param patch patch version number
* @param preReleaseType first pre-release element
* @param preReleaseNumber second pre-release element
* @return {@code SemanticVersionNumber} instance
* @throws IllegalArgumentException if any argument is negative or if the
* preReleaseType is null, empty or not
* alphanumeric (0-9, A-Z, a-z, - only)
* @since 2022-02-19
* @since v0.4.0
*/
public static SemanticVersionNumber preRelease(int major, int minor,
int patch, String preReleaseType, int preReleaseNumber) {
if (major < 0)
throw new IllegalArgumentException(
"Major version must be non-negative.");
if (minor < 0)
throw new IllegalArgumentException(
"Minor version must be non-negative.");
if (patch < 0)
throw new IllegalArgumentException(
"Patch version must be non-negative.");
Objects.requireNonNull(preReleaseType, "preReleaseType may not be null");
if (!VALID_IDENTIFIER.matcher(preReleaseType).matches())
throw new IllegalArgumentException(
String.format("Invalid identifier \"%s\".", preReleaseType));
if (preReleaseNumber < 0)
throw new IllegalArgumentException(
"Pre-release number must be non-negative.");
return new SemanticVersionNumber(major, minor, patch,
List.of(preReleaseType, Integer.toString(preReleaseNumber)),
List.of());
}
/**
* Creates a {@code SemanticVersionNumber} instance without pre-release
* identifiers or build metadata.
*
* Note: this method allows you to create versions with major version number
* 0, even though these versions would not be considered stable.
*
* @param major major version number
* @param minor minor version number
* @param patch patch version number
* @return {@code SemanticVersionNumber} instance
* @throws IllegalArgumentException if any argument is negative
* @since 2022-02-19
* @since v0.4.0
*/
public static SemanticVersionNumber stableVersion(int major, int minor,
int patch) {
if (major < 0)
throw new IllegalArgumentException(
"Major version must be non-negative.");
if (minor < 0)
throw new IllegalArgumentException(
"Minor version must be non-negative.");
if (patch < 0)
throw new IllegalArgumentException(
"Patch version must be non-negative.");
return new SemanticVersionNumber(major, minor, patch, List.of(),
List.of());
}
// parts of the version number
private final int major;
private final int minor;
private final int patch;
private final List preReleaseIdentifiers;
private final List buildMetadata;
/**
* Creates a version number
*
* @param major major version number
* @param minor minor version number
* @param patch patch version number
* @param preReleaseIdentifiers pre-release version data
* @param buildMetadata build metadata
* @since 2022-02-19
* @since v0.4.0
*/
private SemanticVersionNumber(int major, int minor, int patch,
List preReleaseIdentifiers, List buildMetadata) {
this.major = major;
this.minor = minor;
this.patch = patch;
this.preReleaseIdentifiers = preReleaseIdentifiers;
this.buildMetadata = buildMetadata;
}
/**
* @return build metadata (empty if there is none)
* @since 2022-02-19
* @since v0.4.0
*/
public List buildMetadata() {
return Collections.unmodifiableList(this.buildMetadata);
}
/**
* Compares two version numbers according to the official Semantic Versioning
* order.
*
* Note: this ordering is not consistent with equals. Specifically, two
* versions that are identical except for their build metadata will be
* considered different by equals but the same by this method. This is
* required to follow the official Semantic Versioning specification.
*
*/
@Override
public int compareTo(SemanticVersionNumber o) {
// test the three big numbers in order first
if (this.major < o.major)
return -1;
if (this.major > o.major)
return 1;
if (this.minor < o.minor)
return -1;
if (this.minor > o.minor)
return 1;
if (this.patch < o.patch)
return -1;
if (this.patch > o.patch)
return 1;
// now we just compare pre-release identifiers
// (remember: build metadata is ignored)
return SemanticVersionNumber.compareIdentifiers(
this.preReleaseIdentifiers, o.preReleaseIdentifiers);
}
/**
* Determines the compatibility of code written for this version to
* {@code other}. More specifically:
*
* If this function returns true, then there should be no problems
* upgrading code written for this version to version {@code other} as long
* as:
*
* - Semantic Versioning is being used properly
*
- Your code doesn't depend on unintended features (if it does, it isn't
* necessarily compatible with any other version)
*
* If this function returns false, you may have to change your code to
* upgrade it to {@code other}
*
*
* Two version numbers that are identical (ignoring build metadata) are
* always compatible. Different version numbers are compatible as long as:
*
* - The major version number is not 0 (if it is, the API is considered
* unstable and any upgrade can be backwards incompatible)
*
- The major version number is the same (changing the major version
* number implies backwards incompatible changes)
*
- This version comes before the other one in the official precedence
* order (downgrading can remove features you depend on)
*
*
* @param other version to compare with
* @return true if you can definitely upgrade to {@code other} without
* changing code
* @since 2022-02-20
* @since v0.4.0
*/
public boolean compatibleWith(SemanticVersionNumber other) {
Objects.requireNonNull(other, "other may not be null");
return this.compareTo(other) == 0 || this.major != 0
&& this.major == other.major && this.compareTo(other) < 0;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof SemanticVersionNumber))
return false;
final var other = (SemanticVersionNumber) obj;
if (this.buildMetadata == null) {
if (other.buildMetadata != null)
return false;
} else if (!this.buildMetadata.equals(other.buildMetadata))
return false;
if ((this.major != other.major) || (this.minor != other.minor)
|| (this.patch != other.patch))
return false;
if (this.preReleaseIdentifiers == null) {
if (other.preReleaseIdentifiers != null)
return false;
} else if (!this.preReleaseIdentifiers
.equals(other.preReleaseIdentifiers))
return false;
return true;
}
@Override
public int hashCode() {
final var prime = 31;
var result = 1;
result = prime * result
+ (this.buildMetadata == null ? 0 : this.buildMetadata.hashCode());
result = prime * result + this.major;
result = prime * result + this.minor;
result = prime * result + this.patch;
result = prime * result + (this.preReleaseIdentifiers == null ? 0
: this.preReleaseIdentifiers.hashCode());
return result;
}
/**
* @return true iff this version is stable (major version > 0 and not a
* pre-release)
* @since 2022-02-19
* @since v0.4.0
*/
public boolean isStable() {
return this.major > 0 && this.preReleaseIdentifiers.isEmpty();
}
/**
* @return the MAJOR version number, incremented when you make backwards
* incompatible API changes
* @since 2022-02-19
* @since v0.4.0
*/
public int majorVersion() {
return this.major;
}
/**
* @return the MINOR version number, incremented when you add backwards
* compatible functionality
* @since 2022-02-19
* @since v0.4.0
*/
public int minorVersion() {
return this.minor;
}
/**
* @return the PATCH version number, incremented when you make backwards
* compatible bug fixes
* @since 2022-02-19
* @since v0.4.0
*/
public int patchVersion() {
return this.patch;
}
/**
* @return identifiers describing this pre-release (empty if not a
* pre-release)
* @since 2022-02-19
* @since v0.4.0
*/
public List preReleaseIdentifiers() {
return Collections.unmodifiableList(this.preReleaseIdentifiers);
}
/**
* Converts a version number to a string using the official SemVer format.
* The core of a version is MAJOR.MINOR.PATCH, without zero-padding. If
* pre-release identifiers are present, they are separated by periods and
* added after a '-'. If build metadata is present, it is separated by
* periods and added after a '+'. Pre-release identifiers go before version
* metadata.
*
* For example, the version with major number 3, minor number 2, patch number
* 1, pre-release identifiers "alpha" and "1" and build metadata "2022-02-19"
* has a string representation "3.2.1-alpha.1+2022-02-19".
*
* @since v0.4.0
* @see The official SemVer specification
*/
@Override
public String toString() {
var versionString = String.format("%d.%d.%d", this.major, this.minor,
this.patch);
if (!this.preReleaseIdentifiers.isEmpty()) {
versionString += "-" + String.join(".", this.preReleaseIdentifiers);
}
if (!this.buildMetadata.isEmpty()) {
versionString += "+" + String.join(".", this.buildMetadata);
}
return versionString;
}
}