/** * 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.Matcher; import java.util.regex.Pattern; /** * A version number in the Semantic Versioning * scheme *

* Each version number has three main parts: *

    *
  1. The major version, which increments when backwards incompatible changes * are made *
  2. The minor version, which increments when backwards compatible feature * changes are made *
  3. The patch version, which increments when backwards compatible bug fixes * are made *
* * @since v0.4.0 * @since 2022-02-19 */ public final class SemanticVersionNumber implements Comparable { /** * 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 Builder 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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 int naturalComparison = o1.compareTo(o2); if (naturalComparison == 0) return SemanticVersionNumber.compareIdentifiers(o1.buildMetadata, o2.buildMetadata); else 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 v0.4.0 * @since 2022-02-19 */ public static final 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 v0.4.0 * @since 2022-02-20 */ private static final int compareIdentifiers(List a, List b) { // test pre-release size final int aSize = a.size(); final int bSize = b.size(); // no identifiers is greater than any identifiers if (aSize != 0 && bSize == 0) return -1; else if (aSize == 0 && bSize != 0) return 1; // test identifiers one by one for (int i = 0; i < Math.min(aSize, bSize); i++) { final String aElement = a.get(i); final String bElement = b.get(i); if (NUMERIC_IDENTIFER.matcher(aElement).matches()) { if (NUMERIC_IDENTIFER.matcher(bElement).matches()) { // both are numbers, compare them final int aNumber = Integer.parseInt(aElement); final int bNumber = Integer.parseInt(bElement); if (aNumber < bNumber) return -1; else if (aNumber > bNumber) return 1; } else // aElement is a number and bElement is not a number // by the rules, a goes before b 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 int comparison = aElement.compareTo(bElement); if (comparison != 0) return comparison; } } } // we just tested the stuff that's in common, maybe someone has more if (aSize < bSize) return -1; else if (aSize > bSize) return 1; else return 0; } /** * Gets a version number from a string in the official format * * @param versionString string to parse * @return {@code SemanticVersionNumber} instance * @since v0.4.0 * @since 2022-02-19 * @see #toString */ public static final SemanticVersionNumber fromString(String versionString) { // parse & validate version string Objects.requireNonNull(versionString, "versionString may not be null"); final Matcher 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 int major = Integer.parseInt(m.group(1)); final int minor = Integer.parseInt(m.group(2)); final int 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 v0.4.0 * @since 2022-02-19 */ public static final 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 v0.4.0 * @since 2022-02-19 */ public static final 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 v0.4.0 * @since 2022-02-19 */ public static final 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 v0.4.0 * @since 2022-02-19 */ 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 v0.4.0 * @since 2022-02-19 */ 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; else if (this.major > o.major) return 1; if (this.minor < o.minor) return -1; else if (this.minor > o.minor) return 1; if (this.patch < o.patch) return -1; else 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: *

* 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: *

* * @param other version to compare with * @return true if you can definitely upgrade to {@code other} without * changing code * @since v0.4.0 * @since 2022-02-20 */ 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 SemanticVersionNumber 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) return false; if (this.minor != other.minor) return false; if (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 int prime = 31; int 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 v0.4.0 * @since 2022-02-19 */ public boolean isStable() { return this.major > 0 && this.preReleaseIdentifiers.isEmpty(); } /** * @return the MAJOR version number, incremented when you make backwards * incompatible API changes * @since v0.4.0 * @since 2022-02-19 */ public int majorVersion() { return this.major; } /** * @return the MINOR version number, incremented when you add backwards * compatible functionality * @since v0.4.0 * @since 2022-02-19 */ public int minorVersion() { return this.minor; } /** * @return the PATCH version number, incremented when you make backwards * compatible bug fixes * @since v0.4.0 * @since 2022-02-19 */ public int patchVersion() { return this.patch; } /** * @return identifiers describing this pre-release (empty if not a * pre-release) * @since v0.4.0 * @since 2022-02-19 */ 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() { String 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; } }