SemanticVersion.java
package xyz.apollosoftware.bibliothiki.versioning.schemes.semver;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import xyz.apollosoftware.bibliothiki.versioning.Version;
import xyz.apollosoftware.bibliothiki.versioning.VersioningException;
import java.util.*;
import java.util.function.Predicate;
import java.util.regex.Pattern;
/**
* A Semantic Versioning (SemVer) version.
*
* <p>
* This is an immutable representation of a SemVer string.
*
* <p><b>Implementation Note:</b></p>
* This class has a natural ordering that is inconsistent with
* equals. Semantic Versioning (SemVer) does not consider build metadata when
* determining precedence, however it is included naturally for
* {@link #equals(Object)} to maintain principle of least surprise.
*
* <p>
* Therefore, if you wish to compare Semantic Versions for equality per the
* SemVer specification, you should use {@link #compareTo(SemanticVersion)}
*
* @param major The major version component (for breaking API changes).
* @param minor The minor version component (for backwards-compatible
* functionality).
* @param patch The patch version component (for backwards-compatible bug
* fixes).
* @param preRelease The list of pre-release identifiers (see
* {@link PreReleaseIdentifier}).
* @param buildMetadata The list of build metadata identifiers (see
* {@link BuildIdentifier}).
*/
public record SemanticVersion(
int major,
int minor,
int patch,
List<PreReleaseIdentifier> preRelease,
List<BuildIdentifier> buildMetadata
) implements Version<SemanticVersion> {
/**
* A builder for a {@link SemanticVersion}.
*/
public static final class Builder {
private int major;
private int minor;
private int patch;
private final List<PreReleaseIdentifier> preRelease;
private final List<BuildIdentifier> buildMetadata;
private Builder() {
this.preRelease = new ArrayList<>();
this.buildMetadata = new ArrayList<>();
}
private Builder(@NonNull SemanticVersion version) {
this.major = version.major();
this.minor = version.minor();
this.patch = version.patch();
this.preRelease = new ArrayList<>(version.preRelease());
this.buildMetadata = new ArrayList<>(version.buildMetadata());
}
/**
* Set the core version components (major, minor and patch) all at once.
*
* @param major The major version component.
* @param minor The minor version component.
* @param patch The patch version component.
* @return The builder instance.
*/
@NonNull
public Builder version(int major, int minor, int patch) {
this.major = major;
this.minor = minor;
this.patch = patch;
return this;
}
/**
* Set the major version component.
*
* @param major The major version component.
* @return The builder instance.
*/
@NonNull
public Builder major(int major) {
this.major = major;
return this;
}
/**
* Set the minor version component.
*
* @param minor The minor version component.
* @return The builder instance.
*/
@NonNull
public Builder minor(int minor) {
this.minor = minor;
return this;
}
/**
* Set the patch version component.
*
* @param patch The patch version component.
* @return The builder instance.
*/
@NonNull
public Builder patch(int patch) {
this.patch = patch;
return this;
}
/**
* Set the pre-release components.
*
* <p>
* This method will overwrite any existing pre-release identifiers.
*
* @param preRelease The pre-release version components.
* @return The builder instance.
*/
@NonNull
public Builder preRelease(@NonNull PreReleaseIdentifier @NonNull ... preRelease) {
this.preRelease.clear();
this.preRelease.addAll(Arrays.asList(preRelease));
return this;
}
/**
* Add to the pre-release components.
*
* <p>
* This method parses the strings into {@link PreReleaseIdentifier}
* objects for convenience.
*
* @param preRelease The pre-release version components to add.
* @return The builder instance.
* @see #addPreRelease(PreReleaseIdentifier...)
*/
@NonNull
public Builder addPreRelease(@NonNull String @NonNull ... preRelease) {
this.preRelease.addAll(Arrays.stream(preRelease).map(PreReleaseIdentifier::parse).toList());
return this;
}
/**
* Add to the pre-release components.
*
* @param preRelease The pre-release version components to add.
* @return The builder instance.
* @see #addPreRelease(String...)
*/
@NonNull
public Builder addPreRelease(@NonNull PreReleaseIdentifier @NonNull ... preRelease) {
this.preRelease.addAll(Arrays.asList(preRelease));
return this;
}
/**
* Set the build metadata components.
*
* <p>
* This method will overwrite any existing build metadata components.
*
* @param buildMetadata The build metadata components.
* @return The builder instance.
*/
@NonNull
public Builder buildMetadata(@NonNull BuildIdentifier @NonNull ... buildMetadata) {
this.buildMetadata.clear();
this.buildMetadata.addAll(Arrays.asList(buildMetadata));
return this;
}
/**
* Add to the build metadata components.
*
* <p>
* This method parses the strings into {@link BuildIdentifier} objects
* for convenience.
*
* @param buildMetadata The build metadata components to add.
* @return The builder instance.
* @see #addBuildMetadata(BuildIdentifier...)
*/
@NonNull
public Builder addBuildMetadata(@NonNull String @NonNull ... buildMetadata) {
this.buildMetadata.addAll(Arrays.stream(buildMetadata).map(BuildIdentifier::new).toList());
return this;
}
/**
* Add to the build metadata components.
*
* @param buildMetadata The build metadata components to add.
* @return The builder instance.
* @see #addBuildMetadata(String...)
*/
@NonNull
public Builder addBuildMetadata(@NonNull BuildIdentifier @NonNull ... buildMetadata) {
this.buildMetadata.addAll(Arrays.asList(buildMetadata));
return this;
}
/**
* Build the {@link SemanticVersion} representation from the properties
* set on the builder.
*
* @return The built {@link SemanticVersion} instance.
*/
@NonNull
public SemanticVersion build() {
return new SemanticVersion(major, minor, patch, preRelease, buildMetadata);
}
}
/**
* Construct a new {@link Builder} for {@link SemanticVersion}
* representations.
*
* @return The new builder instance.
*/
@NonNull
public static Builder builder() {
return new Builder();
}
/**
* Construct a {@link Builder} that has the current version's values set as
* initial values.
*
* @return The new builder instance.
*/
@NonNull
public Builder asBuilder() {
return new Builder(this);
}
/**
* Constructor for semantic version representations.
*
* <p>
* If {@link #preRelease} or {@link #buildMetadata} are null, they are
* replaced with empty lists. They are also both frozen to ensure they are
* immutable.
*
* @param major The major version component (for breaking API changes).
* @param minor The minor version component (for backwards-compatible
* functionality).
* @param patch The patch version component (for backwards-compatible bug
* fixes).
* @param preRelease The list of pre-release identifiers (see
* {@link PreReleaseIdentifier}).
* @param buildMetadata The list of build metadata identifiers (see
* {@link BuildIdentifier}).
*/
public SemanticVersion {
if (major < 0) throw new VersioningException("Major version must be zero or positive.");
if (minor < 0) throw new VersioningException("Minor version must be zero or positive.");
if (patch < 0) throw new VersioningException("Patch version must be zero or positive.");
preRelease = Collections.unmodifiableList(Optional.ofNullable(preRelease).orElseGet(Collections::emptyList));
buildMetadata = Collections.unmodifiableList(Optional.ofNullable(buildMetadata).orElseGet(Collections::emptyList));
for (int i = 0; i < preRelease.size(); i++) {
if (Objects.isNull(preRelease.get(i))) {
throw new NullPointerException("Pre-release component %d is null".formatted(i));
}
}
for (int i = 0; i < buildMetadata.size(); i++) {
if (Objects.isNull(buildMetadata.get(i))) {
throw new NullPointerException("Build metadata component %d is null".formatted(i));
}
}
}
/**
* Whether the version is a pre-release version.
*
* <p>
* This is true when there are one, or more, pre-release components in the
* version string.
*
* @return True if, and only if, this is a pre-release version.
*/
public boolean isPreRelease() {
return !preRelease.isEmpty();
}
/**
* Whether the version has build metadata.
*
* <p>
* This is true when there are one, or more, build metadata components in
* the version string.
*
* @return True if, and only if, this version string has build metadata.
*/
public boolean hasBuildMetadata() {
return !buildMetadata.isEmpty();
}
/**
* The list of pre-release identifiers (see {@link PreReleaseIdentifier}).
*
* <p>
* The constructor for {@link SemanticVersion} ensures this is non-null
* (non pre-release versions have an empty list, instead).
*
* @return The list of {@link PreReleaseIdentifier}s.
*/
@Override
@NonNull
public List<PreReleaseIdentifier> preRelease() {
return preRelease;
}
/**
* The list of build metadata identifiers (see {@link BuildIdentifier}).
*
* <p>
* The constructor for {@link SemanticVersion} ensures this is non-null
* (versions with no build metadata have an empty list, instead).
*
* @return The list of {@link BuildIdentifier}s.
*/
@Override
@NonNull
public List<BuildIdentifier> buildMetadata() {
return buildMetadata;
}
@Override
public int compareTo(@NonNull final SemanticVersion other) {
Objects.requireNonNull(other, "The version to compare to must not be null");
// Compare core version components (since versions are unsigned per
// SemVer, this is with compareUnsigned as it is assumed any values that
// would ever appear negative here - e.g., as a result of code changes
// also interpret those values as unsigned). Regardless this is now and
// should always be validated elsewhere.
if (major() != other.major()) return Integer.compareUnsigned(major(), other.major());
if (minor() != other.minor()) return Integer.compareUnsigned(minor(), other.minor());
if (patch() != other.patch()) return Integer.compareUnsigned(patch(), other.patch());
// A version with no pre-release fields has higher precedence than a
// version that has pre-release fields.
final var hasPreReleaseComparison = Boolean.compare(!isPreRelease(), !other.isPreRelease());
if (hasPreReleaseComparison != 0) return hasPreReleaseComparison;
// Compare pre-release version components.
for (int i = 0; i < Math.min(preRelease().size(), other.preRelease().size()); i++) {
// If any of the common pre-release versions are different, return
// the comparison.
final var preReleaseComparison = preRelease().get(i).compareTo(other.preRelease().get(i));
if (preReleaseComparison != 0) return preReleaseComparison;
}
// A larger set of pre-release fields as a higher precedence than a
// smaller set (if the preceding are equal).
return Integer.compareUnsigned(preRelease.size(), other.preRelease().size());
}
/**
* Types of Semantic Versioning (SemVer) increments.
*/
public enum IncrementType {
/**
* Increment the major version.
*
* <p>
* This should be incremented for incompatible (breaking) API changes.
*/
MAJOR,
/**
* Increment the minor version.
*
* <p>
* This should be incremented for functionality that has been added in
* a backwards-compatible manner.
*/
MINOR,
/**
* Increment the patch version.
*
* <p>
* This should be incremented for backwards-compatible bug fixes.
*/
PATCH,
}
/**
* Increments the version by performing an increment of the specified
* {@link IncrementType}.
*
* <p>
* This method increments the version to the next non-prerelease version.
* To increment a pre-release version, use
* {@link #withIncrementedPreRelease(String)} or
* {@link #withIncrementedPreRelease(String, int)}.
*
* @param incrementType The type of increment to perform.
* @return A new {@link SemanticVersion}, derived from the current one by
* performing the specified increment.
* @see #withIncrementedPreRelease(String)
* @see #withIncrementedPreRelease(String, int)
*/
@NonNull
public SemanticVersion withIncremented(@NonNull IncrementType incrementType) {
Objects.requireNonNull(incrementType, "The increment type must be specified but was null");
return switch (incrementType) {
case MAJOR -> asBuilder().major(major() + 1).minor(0).patch(0).preRelease().build();
case MINOR -> asBuilder().minor(minor() + 1).patch(0).preRelease().build();
case PATCH -> asBuilder().patch(patch() + 1).preRelease().build();
};
}
/**
* Increments the specified pre-release version.
*
* <p>
* The behavior is identical to {@link #withIncrementedPreRelease(String, int)}
* except the {@code initialValue} is defined as one for convenience.
*
* @param name The name of the pre-release version track.
* @return The incremented version.
* @see #withIncremented(IncrementType)
* @see #withIncrementedPreRelease(String, int)
*/
@NonNull
public SemanticVersion withIncrementedPreRelease(@NonNull String name) {
return withIncrementedPreRelease(name, 1);
}
/**
* Increments the specified pre-release version.
*
* <p>
* Unlike most other functions in the versioning library, this method is
* fairly opinionated and describes a specific (but interoperable) approach
* to versioning with pre-release versions. If you need more specific
* behavior, it may be preferable to implement your own increment method
* using the {@link SemanticVersion} API.
*
* <p>
* If a pre-release version component is present in the string that matches
* the given name and there is a numeric component immediately following it,
* the numeric component is incremented. Otherwise, to avoid modifying
* unintended parts of the version string, the name and {@code initialValue}
* are instead appended to the new version string. If there are multiple
* occurrences, the first one is matched and incremented.
*
* <p>
* To avoid errors, the {@code name} must not be a numeric identifier when
* parsed as a pre-release identifier. If it is, a
* {@link VersioningException} is thrown.
*
* @param name The name of the pre-release version track (must not be
* numeric).
* @param initialValue The initial value to use (if the pre-release track is
* not present in the version string). The initial
* version must be positive.
* @return The incremented version.
* @throws VersioningException If the specified name is numeric or if the
* initial value is negative.
* @see #withIncremented(IncrementType)
* @see #withIncrementedPreRelease(String)
*/
@NonNull
public SemanticVersion withIncrementedPreRelease(@NonNull String name, int initialValue) {
Objects.requireNonNull(name, "The pre-release version to increment must not be null.");
if (initialValue < 0) {
throw new VersioningException("Pre-release version (%d) must be greater than or equal to zero.".formatted(initialValue));
}
int nameIndex = -1;
for (int i = 0; i < preRelease().size(); i++) {
// If a pre-release component matches the name...
if (!preRelease().get(i).isNumeric() &&
preRelease().get(i).rawValue().equals(name) &&
// ...and there is a next element that is numeric...
preRelease().size() > i + 1 &&
preRelease().get(i + 1).isNumeric()) {
// ...store the index of the element.
nameIndex = i;
break;
}
}
// If there were no occurrences, add a new one.
if (nameIndex == -1) {
return asBuilder().addPreRelease(name, Integer.toString(initialValue)).build();
} else {
final List<PreReleaseIdentifier> preRelease = new ArrayList<>(preRelease());
preRelease.set(nameIndex + 1, PreReleaseIdentifier.preRelease(Integer.toString(Objects.requireNonNull(preRelease.get(nameIndex + 1).numericValue()) + 1)));
return asBuilder().preRelease(preRelease.toArray(PreReleaseIdentifier[]::new)).build();
}
}
@Override
@NonNull
public String toString() {
final var result = new StringBuilder("%d.%d.%d".formatted(major(), minor(), patch()));
// Add pre-release segments.
if (isPreRelease()) {
result.append('-');
for (int i = 0; i < preRelease().size(); i++) {
if (i != 0) result.append('.');
result.append(preRelease().get(i).rawValue());
}
}
// Add build metadata segments.
if (hasBuildMetadata()) {
result.append('+');
for (int i = 0; i < buildMetadata().size(); i++) {
if (i != 0) result.append('.');
result.append(buildMetadata().get(i).value());
}
}
return result.toString();
}
/**
* A pre-release build identifier.
*
* <p>
* Identifiers are parsed as numeric identifiers first and, if not numeric,
* validated as non-numeric identifiers using the regular expression
* provided by the Semantic Versioning (SemVer) specification.
*
* @see PreReleaseIdentifier#parse(String)
*/
public static class PreReleaseIdentifier implements Comparable<PreReleaseIdentifier> {
/**
* A predicate that checks the validity of pre-release identifiers.
*/
private static final Predicate<String> IS_VALID =
Pattern.compile("^((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)$").asMatchPredicate();
/**
* A static sugar for {@link #parse(String)}.
*
* @param value The value to parse.
* @return The parsed {@link PreReleaseIdentifier}.
* @see PreReleaseIdentifier#parse(String)
*/
@NonNull
public static PreReleaseIdentifier preRelease(@NonNull String value) {
return parse(value);
}
/**
* Parse the given value as a {@link PreReleaseIdentifier}.
*
* <p>
* If the value is empty, it will be rejected upon construction as this
* is forbidden by the Semantic Versioning (SemVer) specification.
*
* <p>
* If the value is numeric, it is parsed as a numeric identifier. If the
* identifier starts with a leading zero, but would otherwise be
* numeric, a {@link VersioningException} is thrown.
*
* <p>
* If the value is not numeric, it is parsed as a non-numeric identifier
* and is then validated against the prescribed regular expression (from
* the specification) upon construction. If it is not valid, per the
* regular expression, a {@link VersioningException} is thrown.
*
* @param value The value to parse.
* @return The parsed {@link PreReleaseIdentifier}.
* @throws VersioningException If the identifier is not valid.
*/
@NonNull
public static PreReleaseIdentifier parse(@NonNull String value) {
try {
final int numeric = Integer.parseInt(value);
// Ensure there are no leading zeroes.
if (numeric != 0 && value.startsWith("0")) {
throw new VersioningException("Invalid numeric identifier (must not start with leading zero): '%s'".formatted(value));
}
return new PreReleaseIdentifier(value, numeric);
} catch (final NumberFormatException ignored) {
}
return new PreReleaseIdentifier(value, null);
}
@NonNull
private final String rawValue;
@Nullable
private final Integer numericValue;
private PreReleaseIdentifier(@NonNull final String rawValue, @Nullable Integer numericValue) {
if (rawValue.isEmpty()) {
throw new VersioningException("Pre-release identifiers must not be empty");
}
// Validate against the pattern.
if (!IS_VALID.test(rawValue)) {
throw new VersioningException("Invalid pre-release identifier: '%s'".formatted(rawValue));
}
this.rawValue = rawValue;
this.numericValue = numericValue;
}
/**
* Get the raw string value for the identifier.
*
* @return The raw string value.
*/
@NonNull
public String rawValue() {
return this.rawValue;
}
/**
* Get the numeric value, if there is one.
*
* @return The numeric value, or null if one is not set.
*/
@Nullable
public Integer numericValue() {
return this.numericValue;
}
/**
* Whether the identifier was parsed as a numeric value.
*
* <p>
* This is true when the {@link #numericValue()} is not null.
*
* @return True if, and only if, the identifier has a numeric value.
*/
public boolean isNumeric() {
return this.numericValue() != null;
}
@Override
public String toString() {
return rawValue();
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
PreReleaseIdentifier that = (PreReleaseIdentifier) o;
return Objects.equals(rawValue, that.rawValue) && Objects.equals(numericValue, that.numericValue);
}
@Override
public int hashCode() {
return Objects.hash(rawValue, numericValue);
}
@Override
public int compareTo(@NonNull PreReleaseIdentifier other) {
Objects.requireNonNull(other, "The pre-release identifier to compare to must not be null");
if (!isNumeric() && !other.isNumeric()) {
// Compare lexicographically.
return this.rawValue().compareTo(other.rawValue());
}
if (isNumeric() && other.isNumeric()) {
// Compare numerically.
//noinspection DataFlowIssue - covered by comparison
return this.numericValue().compareTo(other.numericValue());
}
// Otherwise, whichever one is alphabetic has higher precedence.
return Boolean.compare(!isNumeric(), !other.isNumeric());
}
}
/**
* A build metadata identifier.
*
* <p>
* Identifiers are simply retained as a raw string but must only contain
* alphanumeric characters and hyphens.
*
* <p>
* Per the Semantic Versioning (SemVer) specification, build metadata
* identifiers must not be empty.
*
* @param value The build identifier string value.
*/
public record BuildIdentifier(String value) {
/**
* A predicate that checks the validity of build metadata identifiers.
*/
private static final Predicate<String> IS_VALID =
Pattern.compile("^[0-9a-zA-Z-]+$").asMatchPredicate();
/**
* Construct the build metadata identifier.
*
* <p>
* Build identifiers must not be empty so this constructor enforces that
* by ensuring the given value is non-empty.
*
* <p>
* This constructor also enforces the character set requirements for the
* build identifier.
*
* @param value The build metadata value.
*/
public BuildIdentifier {
if (value.isEmpty()) {
throw new VersioningException("Build metadata identifiers must not be empty");
}
if (!IS_VALID.test(value)) {
throw new VersioningException("Invalid build metadata identifier: %s".formatted(value));
}
}
/**
* A static sugar for {@link BuildIdentifier#BuildIdentifier(String)}.
*
* @param value The build identifier value.
* @return The created build identifier.
*/
@NonNull
public static BuildIdentifier buildMetadata(String value) {
return new BuildIdentifier(value);
}
@NonNull
@Override
public String toString() {
return value;
}
}
}