diff --git a/src/main/java/org/tudo/sse/model/ArtifactIdent.java b/src/main/java/org/tudo/sse/model/ArtifactIdent.java index 09ee7c3..78b7721 100644 --- a/src/main/java/org/tudo/sse/model/ArtifactIdent.java +++ b/src/main/java/org/tudo/sse/model/ArtifactIdent.java @@ -5,6 +5,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.tudo.sse.semver.SemanticVersionNumber; +import org.tudo.sse.semver.SemanticVersionParsingException; import org.tudo.sse.utils.MavenCentralRepository; @@ -47,6 +49,11 @@ public class ArtifactIdent { */ private String customRepository; + private boolean didParseSemVer = false; + private boolean semVerValid = false; + private SemanticVersionNumber semVer = null; + private SemanticVersionParsingException semVerException = null; + private static final Logger log = LoggerFactory.getLogger(ArtifactIdent.class); /** @@ -135,6 +142,9 @@ public String getVersion() { public void setVersion(String version) { this.version = version; this.GAV = groupID + ":" + artifactID + ":" + version; + + this.semVer = null; + this.didParseSemVer = false; } /** @@ -209,6 +219,47 @@ public URI getMavenCentralXMLUri() { } } + /** + * Parses this identifier's version according to the semantic versioning 2.0.0 standard. Returns the parsed number, + * or throws an exception if the version does not comply to the required syntax. Note that the result of this method + * invocation is cached, meaning that future calls will return the same parsed semantic version or throw the same + * exception - parsing is only ever attempted once. + * + * @return Semantic version number if parsing was successful. + * @throws SemanticVersionParsingException If version does not adhere to required syntax + */ + public SemanticVersionNumber getSemanticVersion() throws SemanticVersionParsingException { + + if(this.didParseSemVer && this.semVerValid) return this.semVer; + if(this.didParseSemVer) throw this.semVerException; + + try { + this.didParseSemVer = true; + this.semVer = SemanticVersionNumber.parse(this.version); + this.semVerValid = true; + } catch (SemanticVersionParsingException svpx){ + this.semVerException = svpx; + this.semVerValid = false; + throw this.semVerException; + } + + return this.semVer; + } + + /** + * Checks whether this identifier's version is valid according to the semantic versioning 2.0.0 standard. The results + * of this method invocation are cached so that future calls do not have to parse the version string again. + * @return True if this identifier has a valid semantic version, false otherwise + */ + public boolean hasValidSemanticVersion() { + try { + this.getSemanticVersion(); + return true; + } catch(SemanticVersionParsingException svpx){ + return false; + } + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/org/tudo/sse/semver/MavenVersionRangeParser.java b/src/main/java/org/tudo/sse/semver/MavenVersionRangeParser.java new file mode 100644 index 0000000..062b45d --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/MavenVersionRangeParser.java @@ -0,0 +1,107 @@ +package org.tudo.sse.semver; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parser for semantic version ranges as specified by the Maven build system. See the specification + * for details on the supported range types and syntaxes. + * + * @author Johannes Düsing + */ +public class MavenVersionRangeParser { + + private static final char START_INCLUSIVE = '['; + private static final char END_INCLUSIVE = ']'; + + private static final char START_EXCLUSIVE = '('; + private static final char END_EXCLUSIVE = ')'; + + private static final char RANGE_SEPARATOR = ','; + + private MavenVersionRangeParser(){} + + /** + * Parses the given textual representation of a Maven version range into the corresponding version range object. + * @param value Value to parse + * @return Parsed version range, if successful + * @throws SemanticVersionParsingException If either the range syntax or the version number syntax were invalid. + */ + public static SemanticVersionRange parseRange(String value) throws SemanticVersionParsingException { + final char[] chars = value.toCharArray(); + final StringBuilder currentValue = new StringBuilder(); + + boolean inRange = false; + boolean sawSeparator = false; + boolean lowerInclusive = false; + SemanticVersionNumber lowerBound = null; + + List ranges = new ArrayList<>(); + + for(int i = 0; i < chars.length; i++){ + char currentChar = chars[i]; + + // Ignore whitespaces + if(Character.isWhitespace(currentChar)){ + continue; + } + + if(!inRange){ + if(currentChar == START_INCLUSIVE){ + inRange = true; + lowerInclusive = true; + } else if(currentChar == START_EXCLUSIVE){ + inRange = true; + lowerInclusive = false; + } else if(currentChar != RANGE_SEPARATOR){ + throw new SemanticVersionParsingException(value, "Expected begin of new range or range separator but got " + currentChar, i); + } + } else { + if(currentChar == RANGE_SEPARATOR){ + String lowerBoundStr = currentValue.toString(); + currentValue.setLength(0); + + sawSeparator = true; + + if(lowerBoundStr.isBlank()) lowerBound = null; + else lowerBound = SemanticVersionNumber.parse(lowerBoundStr); + } else if(currentChar == END_INCLUSIVE || currentChar == END_EXCLUSIVE){ + String upperBoundStr = currentValue.toString(); + currentValue.setLength(0); + + SemanticVersionNumber upperBound = null; + if(!upperBoundStr.isBlank()) upperBound = SemanticVersionNumber.parse(upperBoundStr); + boolean upperInclusive = (currentChar == END_INCLUSIVE); + + // If we do not see a separator (e.g. range '[1.0]'), we have a hard requirement for one specific + // version. This means lower and upper bound are equal, and both bounds must be inclusive + if(!sawSeparator){ + lowerBound = upperBound; + + if(!lowerInclusive || !upperInclusive){ + throw new SemanticVersionParsingException(value, "Hard requirements for one specific version must have inclusive range delimiters.", i); + } + } + + SimpleSemanticVersionRange range = new SimpleSemanticVersionRange(lowerBound, lowerInclusive, upperBound, upperInclusive); + ranges.add(range); + + inRange = false; + sawSeparator = false; + } else { + currentValue.append(currentChar); + } + } + } + + if(inRange) + throw new SemanticVersionParsingException(value, "Input ended with range not being closed"); + + if(ranges.isEmpty()) + throw new SemanticVersionParsingException(value, "No ranges found in expression: " + value); + else if(ranges.size() == 1) + return ranges.get(0); + else + return new MultiPartSemanticVersionRange(ranges); + } +} diff --git a/src/main/java/org/tudo/sse/semver/MultiPartSemanticVersionRange.java b/src/main/java/org/tudo/sse/semver/MultiPartSemanticVersionRange.java new file mode 100644 index 0000000..5bee54c --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/MultiPartSemanticVersionRange.java @@ -0,0 +1,46 @@ +package org.tudo.sse.semver; + +import java.util.List; + +/** + * Implementation of composite semantic version ranges, which consist of multiple "smaller" range specifications. Those + * ranges are concatenated in a way that this composite range is the union of all partial ranges - if a given version + * number is contained in at least one of the partial ranges, it is also contained in this range. + * + * @author Johannes Düsing + */ +public class MultiPartSemanticVersionRange implements SemanticVersionRange { + + private final List partialRanges; + + /** + * Creates a new range with the given list of partial ranges. Containment of any given version number is checked + * in the same order in which partial ranges a provided here. + * + * @param ranges List of partial ranges + */ + public MultiPartSemanticVersionRange(List ranges){ + this.partialRanges = ranges; + } + + @Override + public boolean contains(SemanticVersionNumber number) { + for(SemanticVersionRange range : partialRanges){ + if(range.contains(number)) return true; + } + return false; + } + + @Override + public String toString(){ + StringBuilder sb = new StringBuilder(); + + for(int i = 0; i < partialRanges.size(); i++){ + SemanticVersionRange range = partialRanges.get(i); + sb.append(range.toString()); + if(i != partialRanges.size()-1) sb.append("."); + } + + return sb.toString(); + } +} diff --git a/src/main/java/org/tudo/sse/semver/SemanticVersionNumber.java b/src/main/java/org/tudo/sse/semver/SemanticVersionNumber.java new file mode 100644 index 0000000..496a5f2 --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/SemanticVersionNumber.java @@ -0,0 +1,320 @@ +package org.tudo.sse.semver; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Class representing a version number as defined by the semantic versioning 2.0.0 standard. See this webpage for + * the formal definition. This class implements all comparison logic for version numbers as defined by the standard. + * + *

+ * Please Note: Build Metadata can be attached to semantic version numbers, but is irrelevant when comparing numbers. + * This is explicitly specified in the standard. This class adheres to those definitions and ignores build metadata when + * comparing numbers (compareTo) or checking for equality (equals, hashcode). + *

+ * + * + * @author Johannes Düsing + */ +public class SemanticVersionNumber implements Comparable { + + private final int majorVersion; + private final int minorVersion; + private final int patchVersion; + + private final String preReleaseData; + private final String buildMetadata; + + private List _parsedPreReleases = null; + + /** + * Creates a new semantic version number with the given major, minor and patch version. + * @param majorVersion Major version of this number + * @param minorVersion Minor version of this number + * @param patchVersion Patch version of this number + */ + public SemanticVersionNumber(int majorVersion, int minorVersion, int patchVersion) { + this(majorVersion, minorVersion, patchVersion, null); + } + + /** + * Creates a new semantic version number with the given major, minor and patch versions, as well as a prerelease string. + * @param majorVersion Major version of this number + * @param minorVersion Minor version of this number + * @param patchVersion Patch version of this number + * @param preReleaseData Prelease identifier of this number + */ + public SemanticVersionNumber(int majorVersion, int minorVersion, int patchVersion, String preReleaseData) { + this(majorVersion, minorVersion, patchVersion, preReleaseData, null); + } + + /** + * Creates a new semantic version number with the given major, minor and patch versions, as well as a prerelease and build identifier. + * @param majorVersion Major version of this number + * @param minorVersion Minor version of this number + * @param patchVersion Patch version of this number + * @param preReleaseData Prelease identifier of this number + * @param buildMetadata The build metadata identifier of this number - not relevant for comparisons + */ + public SemanticVersionNumber(int majorVersion, int minorVersion, int patchVersion, String preReleaseData, String buildMetadata) { + this.majorVersion = majorVersion; + this.minorVersion = minorVersion; + this.patchVersion = patchVersion; + + this.preReleaseData = preReleaseData; + this.buildMetadata = buildMetadata; + } + + /** + * Get this number's major version. + * @return The major version + */ + public int getMajorVersion(){ + return this.majorVersion; + } + + /** + * Get this number's minor version. + * @return The minor version + */ + public int getMinorVersion(){ + return this.minorVersion; + } + + /** + * Get this number's patch version. + * @return The patch version + */ + public int getPatchVersion(){ + return this.patchVersion; + } + + /** + * Checks whether this semantic version number includes a prerelease identifier. + * @return True if there is a prerelease identifier, false otherwise + */ + public boolean hasPreRelease(){ + return this.preReleaseData != null; + } + + /** + * Checks whether this semantic version number includes build metadata. + * @return True if there is metadata, false otherwise + */ + public boolean hasBuildMetadata(){ + return this.buildMetadata != null; + } + + /** + * Get this number's prerelease identifier, if available. + * @return The prerelease identifier, or null, if none is available. + */ + public String getPreRelease() { + return this.preReleaseData; + } + + /** + * Get this number's build metadata, if available. + * @return The build metadata, or null, if none is available. + */ + public String getBuildMetadata() { + return this.buildMetadata; + } + + @Override + public final int compareTo(SemanticVersionNumber other){ + // The precedence of semantic version V2 numbers is defined here: https://semver.org/#spec-item-11 + // Note that build metadata is irrelevant to precedence + // Major version takes precedence + if(this.majorVersion < other.majorVersion) return -1; + else if(this.majorVersion > other.majorVersion) return 1; + else { + // Minor version is second deciding factor + if(this.minorVersion < other.minorVersion) return -1; + else if(this.minorVersion > other.minorVersion) return 1; + else { + // Patch version is third deciding factor + if(this.patchVersion < other.patchVersion) return -1; + else if(this.patchVersion > other.patchVersion) return 1; + else { + // If major, minor and patch are equal, we proceed as follows: + // - if both versions have no prerelease data, they are equal + // - if one version has prerelease data and the other has not, prerelease data takes *lower* precedence, i.e. 1.0.0-alpha < 1.0.0 + if(!this.hasPreRelease() && !other.hasPreRelease()) return 0; + else if(!this.hasPreRelease() && other.hasPreRelease()) return 1; + else if(this.hasPreRelease() && !other.hasPreRelease()) return -1; + else { + // If both versions have prerelease data, we must parse this data. SemVer specifies that it may + // consist of dot-separated identifiers that are either numeric or textual. + List thisParts = this.getParsedPreReleaseParts(); + List otherParts = other.getParsedPreReleaseParts(); + + // The first difference in prerelease parts decides precedence + int pos = 0; + while(pos < thisParts.size() && pos < otherParts.size()) { + int compareResult = thisParts.get(pos).compareTo(otherParts.get(pos)); + if(compareResult != 0) return compareResult; + pos += 1; + } + + // If we do not find a difference for indices valid in both lists: Fewer parts equal less precedence + return thisParts.size() - otherParts.size(); + } + } + } + } + } + + private List getParsedPreReleaseParts(){ + if(this._parsedPreReleases == null) { + this._parsedPreReleases = this.parsePreReleaseData(); + } + + return this._parsedPreReleases; + } + + private List parsePreReleaseData(){ + List preReleaseParts = new ArrayList<>(); + + StringBuilder current = new StringBuilder(); + for(int i = 0; i < preReleaseData.length(); i++){ + char c = preReleaseData.charAt(i); + if(c == '.'){ + String currValue = current.toString(); + + try { + int currInt = Integer.parseInt(currValue); + preReleaseParts.add(new PreReleasePart(currInt)); + } catch(NumberFormatException ignored){ + preReleaseParts.add(new PreReleasePart(currValue)); + } + current.setLength(0); + } else { + current.append(c); + } + } + + String currValue = current.toString(); + + try { + int currInt = Integer.parseInt(currValue); + preReleaseParts.add(new PreReleasePart(currInt)); + } catch(NumberFormatException ignored){ + preReleaseParts.add(new PreReleasePart(currValue)); + } + + return preReleaseParts; + } + + /** + * Class representing a part of a prerelase identifier. The SemVer v2 spec allows prerelease identifiers to be composed + * of dot-separated parts, which can either be numbers or strings. This class implements their comparison logic + * in compliance with semantic versioning rules. + */ + private static final class PreReleasePart implements Comparable{ + + private final String stringPart; + private final Integer intPart; + + PreReleasePart(String value){ + this.stringPart = value; + this.intPart = null; + } + + PreReleasePart(int value){ + this.stringPart = null; + this.intPart = value; + } + + boolean isNum() { + return intPart != null; + } + + int numValue(){ + if(isNum()) return intPart; + else throw new IllegalStateException("Not a number"); + } + + boolean isString(){ + return stringPart != null; + } + + String stringValue(){ + if(isString()) return stringPart; + else throw new IllegalStateException("Not a string"); + } + + + @Override + public int compareTo(PreReleasePart other) { + // If both parts are numbers, they are compared numerically + if(this.isNum() && other.isNum()) return this.numValue() - other.numValue(); + // Numeric identifiers have lower precedence than text + else if(this.isNum()) return -1; + else if(other.isNum()) return 1; + else return this.stringValue().compareTo(other.stringValue()); + } + } + + /** + * Attempts to create a new semantic version number by parsing the given string value. If the string does not represent + * a valid number according to the semantic versioning 2.0.0 standard, an exception is thrown. + * @param value The string value to parse + * @return The semantic version number if parsing was successful + * @throws SemanticVersionParsingException If the value was invalid + */ + public static SemanticVersionNumber parse(String value) throws SemanticVersionParsingException { + return SemanticVersionParser.parseNumber(value); + } + + /** + * Attempts to create a new semantic version number by parsing the given string value. If the string does not represent + * a valid number according to the semantic versioning 2.0.0 standard, an empty Optional is returned. + * @param value The string value to parse + * @return Optional value containing either the parsed number (if parsing was successful), or nothing + */ + public static Optional tryParse(String value) { + try { + SemanticVersionNumber number = parse(value); + return Optional.of(number); + } catch (SemanticVersionParsingException svpx){ + return Optional.empty(); + } + } + + @Override + public String toString(){ + StringBuilder sb = new StringBuilder(); + sb.append(majorVersion); + sb.append("."); + sb.append(minorVersion); + sb.append("."); + sb.append(patchVersion); + + if(this.hasPreRelease()){ + sb.append("-"); + sb.append(preReleaseData); + } + + if(this.hasBuildMetadata()){ + sb.append("+"); + sb.append(buildMetadata); + } + + return sb.toString(); + } + + @Override + public boolean equals(Object other){ + if(!(other instanceof SemanticVersionNumber)) return false; + + return this.compareTo((SemanticVersionNumber)other) == 0; + } + + @Override + public int hashCode(){ + return Objects.hash(majorVersion, minorVersion, patchVersion, preReleaseData); + } +} diff --git a/src/main/java/org/tudo/sse/semver/SemanticVersionParser.java b/src/main/java/org/tudo/sse/semver/SemanticVersionParser.java new file mode 100644 index 0000000..524a29a --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/SemanticVersionParser.java @@ -0,0 +1,193 @@ +package org.tudo.sse.semver; + +class SemanticVersionParser { + + static SemanticVersionNumber parseNumber(String value) throws SemanticVersionParsingException { + if(value.isBlank()) throw new SemanticVersionParsingException(value, "Semantic version cannot be empty", 0); + + ParsingState state = ParsingState.NUMBER; + int parsingPosition = 0; + StringBuilder currentValue = new StringBuilder(); + + int currNumberPartIdx = 0; + int[] numberParts = new int[] {-1, 0, 0}; + + String preRelease = null; + String buildMetadata = null; + + final char[] characters = value.toCharArray(); + + for(char currentChar: characters) { + + switch(state){ + case NUMBER: + if(currentChar != '.' && currentChar != '-' && currentChar != '+') { + currentValue.append(currentChar); + } else { + if(currentValue.length() == 0) + throw new SemanticVersionParsingException(value, "Numeric identifier must have at least one digit", parsingPosition); + + final String numValue = currentValue.toString(); + currentValue.setLength(0); + + if(!isNumericIdentifier(numValue)) + throw new SemanticVersionParsingException(value, "Not a valid numeric identifier", parsingPosition); + + numberParts[currNumberPartIdx] = asInt(numValue, value, parsingPosition); + + if(currentChar == '.'){ + if(currNumberPartIdx == 2) + throw new SemanticVersionParsingException(value, "Semantic version cannot have more than three parts", parsingPosition); + + currNumberPartIdx += 1; + } else if(currentChar == '-'){ + state = ParsingState.PRERELEASE; + } else { + state = ParsingState.BUILDMETADATA; + } + } + break; + + case PRERELEASE: + if(currentChar != '+'){ + currentValue.append(currentChar); + } else { + final String preReleaseValue = currentValue.toString(); + currentValue.setLength(0); + + if(!isValidPreRelease(preReleaseValue)) + throw new SemanticVersionParsingException(value, "Not a valid prerelease identifier", parsingPosition); + + preRelease = preReleaseValue; + + state = ParsingState.BUILDMETADATA; + } + break; + case BUILDMETADATA: + currentValue.append(currentChar); + + break; + } + + parsingPosition += 1; + } + + String remaining = currentValue.toString(); + + switch(state){ + case NUMBER: + numberParts[currNumberPartIdx] = asInt(remaining, value, parsingPosition); + break; + case PRERELEASE: + if(!isValidPreRelease(remaining)) + throw new SemanticVersionParsingException(value, "Not a valid prerelease identifier", parsingPosition); + + preRelease = remaining; + break; + case BUILDMETADATA: + if(!isValidBuild(remaining)) + throw new SemanticVersionParsingException(value, "Not a valid build identifier", parsingPosition); + + buildMetadata = remaining; + } + + return new SemanticVersionNumber(numberParts[0], numberParts[1], numberParts[2], preRelease, buildMetadata); + } + + private static boolean isValidBuild(String value){ + if(value.isBlank()) return false; + + final String[] parts = value.split("\\."); + + for(String part: parts){ + if(!isAlphanumericIdentifier(part) && !isDigits(part)) + return false; + } + + return true; + } + + private static boolean isValidPreRelease(String value){ + if(value.isBlank()) return false; + + final String[] parts = value.split("\\."); + + for(String part: parts){ + if(!isAlphanumericIdentifier(part) && !isNumericIdentifier(part)) + return false; + } + + return true; + } + + private static boolean isAlphanumericIdentifier(String value) { + if(value.isEmpty()) return false; + + boolean hasNonDigit = false; + + for(char c : value.toCharArray()) { + if(isNonDigit(c)) hasNonDigit = true; + + if(!isIdentifierCharacter(c)) return false; + } + + return hasNonDigit; + } + + private static boolean isNumericIdentifier(String value){ + if(value.isEmpty()) return false; + if(value.equals("0")) return true; + + char[] chars = value.toCharArray(); + + if(!isPositiveDigit(chars[0])) return false; + + for(int i = 1; i < chars.length; i++){ + if(!isDigit(chars[i])) return false; + } + + return true; + } + + private static boolean isDigits(String value){ + if(value.isEmpty()) return false; + for(char c : value.toCharArray()){ + if(!isDigit(c)) return false; + } + return true; + } + + private static boolean isIdentifierCharacter(char c){ + return isDigit(c) || isNonDigit(c); + } + + private static boolean isNonDigit(char c){ + return c == '-' || isLetter(c); + } + + private static boolean isLetter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static boolean isPositiveDigit(char c){ + return (c >= '1' && c <= '9'); + } + + private static boolean isDigit(char c) { + return (c >= '0' && c <= '9'); + } + + private static int asInt(String value, String number, int pos) throws SemanticVersionParsingException { + try { + return Integer.parseInt(value); + } catch (NumberFormatException nfx) { + throw new SemanticVersionParsingException(number, "Expected an integer", pos); + } + } + + private enum ParsingState { + NUMBER, + PRERELEASE, + BUILDMETADATA + } +} diff --git a/src/main/java/org/tudo/sse/semver/SemanticVersionParsingException.java b/src/main/java/org/tudo/sse/semver/SemanticVersionParsingException.java new file mode 100644 index 0000000..299123c --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/SemanticVersionParsingException.java @@ -0,0 +1,77 @@ +package org.tudo.sse.semver; + +/** + * Class representing an exception while parsing a semantic version number. + * + * @author Johannes Düsing + */ +public class SemanticVersionParsingException extends Exception { + + /** + * Value that was being parsed + */ + private final String parsedValue; + + /** + * Message describing the error + */ + private final String msg; + + /** + * Position at which the error occurred + */ + private final int parsingPosition; + + /** + * Create a new parsing exception with the given description while parsing the given value. + * @param value The value that was being parsed + * @param description A description of the error that was encountered + */ + public SemanticVersionParsingException(String value, String description) { + this(value, description, -1); + } + + /** + * Create a new parsing exception with the given description, parsing position and parsed value. + * @param value The value that was being parsed + * @param description A description of the error that was encountered + * @param parsingPosition The parsing position at which the error occurred + */ + public SemanticVersionParsingException(String value, String description, int parsingPosition) { + this.parsedValue = value; + this.msg = description; + this.parsingPosition = parsingPosition; + } + + @Override + public String getMessage() { + StringBuilder sb = new StringBuilder("Error parsing value '"); + sb.append(parsedValue); + sb.append("'"); + + if(parsingPosition != -1){ + sb.append(" at position ").append(parsingPosition); + } + + sb.append(" : "); + sb.append(msg); + return sb.toString(); + } + + /** + * Returns the position at which the error occurred in the original value. + * @return Parsing position + */ + public int getParsingPosition() { + return this.parsingPosition; + } + + /** + * Returns the value that was being parsed when the error occurred. + * @return The parsed value + */ + public String getParsedValue(){ + return this.parsedValue; + } + +} diff --git a/src/main/java/org/tudo/sse/semver/SemanticVersionRange.java b/src/main/java/org/tudo/sse/semver/SemanticVersionRange.java new file mode 100644 index 0000000..d11f764 --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/SemanticVersionRange.java @@ -0,0 +1,17 @@ +package org.tudo.sse.semver; + +/** + * Interface that represents a range of semantic version numbers. The only requirement for implementations is that they + * must be able to decide whether any given semantic version number is contained within a range or not. + * + * @author Johannes Düsing + */ +public interface SemanticVersionRange { + + /** + * Checks whether the given semantic version number falls within this range + * @param number The number to check + * @return True if the number is contained within this range, false otherwise + */ + boolean contains(SemanticVersionNumber number); +} diff --git a/src/main/java/org/tudo/sse/semver/SimpleSemanticVersionRange.java b/src/main/java/org/tudo/sse/semver/SimpleSemanticVersionRange.java new file mode 100644 index 0000000..e2202b3 --- /dev/null +++ b/src/main/java/org/tudo/sse/semver/SimpleSemanticVersionRange.java @@ -0,0 +1,152 @@ +package org.tudo.sse.semver; + +/** + * Simple implementation of semantic version ranges. A simple range is defined by two version numbers, a lower bound and + * an upper bound. Both are optional, in which case we have (semi-) open ranges. Bounds are either inclusive or exclusive. + * + * @author Johannes Düsing + */ +public class SimpleSemanticVersionRange implements SemanticVersionRange { + + private final SemanticVersionNumber lowerBound; + private final SemanticVersionNumber upperBound; + private final boolean lowerBoundInclusive; + private final boolean upperBoundInclusive; + + /** + * Creates a new simple semantic version range with the given bounds and their inclusive-specification. Use null to + * indicate that one (or both) bounds do not exist. If a bound is not set, its 'inclusive' value is ignored. + * @param lowerBound The lower bound for this range, or null if there is no lower bound. + * @param lowerBoundInclusive Whether the lower bound is inclusive. Ignored if there is no lower bound. + * @param upperBound The upper bound for this range, or null if there is no upper bound. + * @param upperBoundInclusive Whether the upper bound is inclusive. Ignored if there is no upper bound. + */ + public SimpleSemanticVersionRange(SemanticVersionNumber lowerBound, + boolean lowerBoundInclusive, + SemanticVersionNumber upperBound, + boolean upperBoundInclusive) { + this.lowerBound = lowerBound; + this.lowerBoundInclusive = lowerBoundInclusive; + this.upperBound = upperBound; + this.upperBoundInclusive = upperBoundInclusive; + } + + /** + * Creates a new simple semantic version range that is semi-open, i.e. a range that only has a lower bound. + * @param lowerBound Lower bound for this range. + * @param lowerBoundInclusive Whether the lower bound is inclusive. + * @return Semi-Open range with only a lower bound + */ + public static SimpleSemanticVersionRange fromLowerBound(SemanticVersionNumber lowerBound, boolean lowerBoundInclusive) { + return new SimpleSemanticVersionRange(lowerBound, lowerBoundInclusive, null, false); + } + + /** + * Creates a new simple semantic version range that is semi-open, i.e. a range that only has an upper bound. + * @param upperBound Upper bound for this range. + * @param upperBoundInclusive Whether the upper bound is inclusive. + * @return Semi-Open range with only an upper bound + */ + public static SimpleSemanticVersionRange fromUpperBound(SemanticVersionNumber upperBound, boolean upperBoundInclusive) { + return new SimpleSemanticVersionRange(null, false, upperBound, upperBoundInclusive); + } + + /** + * Checks whether this range has a lower bound. + * @return True if lower bound exists. + */ + public boolean hasLowerBound() { + return lowerBound != null; + } + + /** + * Checs whether this range has an upper bound. + * @return True if upper bound exists. + */ + public boolean hasUpperBound() { + return this.upperBound != null; + } + + /** + * Checks whether the lower bound of this range is inclusive. + * @return True if lower bound is inclusive, false if exclusive. + */ + public boolean isLowerBoundInclusive() { + return lowerBoundInclusive; + } + + /** + * Checks whether the upper bound of this range is inclusive. + * @return True if upper bound is inclusive, false if exclusive. + */ + public boolean isUpperBoundInclusive() { + return upperBoundInclusive; + } + + /** + * Gets the lower bound for this range. + * @return Lower bound, or null if non exists + */ + public SemanticVersionNumber getLowerBound() { + return lowerBound; + } + + /** + * Gets the upper bound for this range. + * @return Upper bound, or null if non exists. + */ + public SemanticVersionNumber getUpperBound() { + return upperBound; + } + + /** + * Checks whether this range represents a (Maven Central) hard requirement. A hard requirement is a special range + * where lower and upper bound are equal, and both are inclusive. This range contains exactly one version number. + * @return True if this range is a hard requirement + */ + public boolean isHardRequirement(){ + return this.lowerBound.equals(this.upperBound); + } + + @Override + public boolean contains(SemanticVersionNumber number) { + if(hasLowerBound()){ + int compareResult = number.compareTo(lowerBound); + if(compareResult < 0 || (!isLowerBoundInclusive() && compareResult == 0)) return false; + } + + if(hasUpperBound()){ + int compareResult = number.compareTo(upperBound); + if(compareResult > 0 || (!isUpperBoundInclusive() && compareResult == 0)) return false; + } + + return true; + } + + @Override + public String toString(){ + StringBuilder sb = new StringBuilder(); + + if(isLowerBoundInclusive()) + sb.append('['); + else + sb.append('('); + + if(hasLowerBound()) + sb.append(lowerBound.toString()); + + if(!isHardRequirement()){ + sb.append(','); + sb.append(' '); + if(hasUpperBound()) + sb.append(upperBound.toString()); + } + + if(isUpperBoundInclusive()) + sb.append(']'); + else + sb.append(')'); + + return sb.toString(); + } +} diff --git a/src/test/java/org/tudo/sse/semver/MavenVersionRangeParserTest.java b/src/test/java/org/tudo/sse/semver/MavenVersionRangeParserTest.java new file mode 100644 index 0000000..79114a2 --- /dev/null +++ b/src/test/java/org/tudo/sse/semver/MavenVersionRangeParserTest.java @@ -0,0 +1,135 @@ +package org.tudo.sse.semver; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for version range parsing are based on the specification + */ +public class MavenVersionRangeParserTest { + + @Test + @DisplayName("The parser must parse hard requirements correctly") + void testHardRequirement_Valid(){ + SimpleSemanticVersionRange simpleRange = assertSimpleRange("[1.0]"); + + assertNotNull(simpleRange); + assertTrue(simpleRange.isHardRequirement()); + assertEquals("1.0.0", simpleRange.getLowerBound().toString()); + } + + @Test + @DisplayName("The parser must parse semi-open simple ranges like (,1.0]") + void testSemiRange1(){ + SimpleSemanticVersionRange simpleRange = assertSimpleRange("(,1.0]"); + assertNotNull(simpleRange); + assertFalse(simpleRange.hasLowerBound()); + assertTrue(simpleRange.hasUpperBound()); + assertFalse(simpleRange.isLowerBoundInclusive()); + assertTrue(simpleRange.isUpperBoundInclusive()); + assertEquals("1.0.0", simpleRange.getUpperBound().toString()); + } + + @Test + @DisplayName("The parser must parse regular simple ranges") + void testFullRange1(){ + SimpleSemanticVersionRange simpleRange = assertSimpleRange("[1.2,1.3.3-Preview+Snapshot.1]"); + assertNotNull(simpleRange); + assertTrue(simpleRange.hasLowerBound()); + assertTrue(simpleRange.hasUpperBound()); + assertTrue(simpleRange.isLowerBoundInclusive()); + assertTrue(simpleRange.isUpperBoundInclusive()); + assertTrue(simpleRange.getUpperBound().hasPreRelease()); + assertTrue(simpleRange.getUpperBound().getBuildMetadata().endsWith(".1")); + } + + @Test + @DisplayName("The parser must parse inclusivity correctly for simple ranges") + void testFullRange2(){ + SimpleSemanticVersionRange simpleRange = assertSimpleRange("[1.0, 2.0)"); + assertNotNull(simpleRange); + assertTrue(simpleRange.hasLowerBound()); + assertTrue(simpleRange.isLowerBoundInclusive()); + assertTrue(simpleRange.hasUpperBound()); + assertFalse(simpleRange.isUpperBoundInclusive()); + assertEquals(2, simpleRange.getUpperBound().getMajorVersion()); + } + + @Test + @DisplayName("The parser must parse semi-open simple ranges like [1.5,)") + void testSemiRange2(){ + SimpleSemanticVersionRange simpleRange = assertSimpleRange("[1.5,)"); + assertNotNull(simpleRange); + assertTrue(simpleRange.hasLowerBound()); + assertTrue(simpleRange.isLowerBoundInclusive()); + assertFalse(simpleRange.hasUpperBound()); + assertFalse(simpleRange.isUpperBoundInclusive()); + assertEquals(5, simpleRange.getLowerBound().getMinorVersion()); + } + + @Test + @DisplayName("The parser must parse complex composite ranges correctly") + void complexRange1(){ + MultiPartSemanticVersionRange multiRange = assertMultiRange("(,1.0],[1.2,)"); + assertNotNull(multiRange); + + var notContained = asSemVer("1.0.1"); + var contained1 = asSemVer("0.0.1"); + var contained2 = asSemVer("1.2"); + + assertFalse(multiRange.contains(notContained)); + assertTrue(multiRange.contains(contained1)); + assertTrue(multiRange.contains(contained2)); + } + + @Test + @DisplayName("The parser must handle inverse hard requirements correctly") + void testInverseHardRequirement(){ + MultiPartSemanticVersionRange multiRange = assertMultiRange("(,1.1),(1.1,)"); + assertNotNull(multiRange); + + var notContained = asSemVer("1.1"); + var contained = asSemVer("1.2"); + + assertFalse(multiRange.contains(notContained)); + assertTrue(multiRange.contains(contained)); + } + + + private SimpleSemanticVersionRange assertSimpleRange(String versionRange){ + try { + var range = MavenVersionRangeParser.parseRange(versionRange); + + assertInstanceOf(SimpleSemanticVersionRange.class, range); + + return (SimpleSemanticVersionRange)range; + } catch (SemanticVersionParsingException svpx){ + fail(svpx); + } + return null; + } + + private MultiPartSemanticVersionRange assertMultiRange(String versionRange){ + try { + var range = MavenVersionRangeParser.parseRange(versionRange); + + assertInstanceOf(MultiPartSemanticVersionRange.class, range); + + return (MultiPartSemanticVersionRange) range; + } catch (SemanticVersionParsingException svpx){ + fail(svpx); + } + return null; + } + + private SemanticVersionNumber asSemVer(String value){ + try { + return SemanticVersionNumber.parse(value); + } catch (SemanticVersionParsingException svpx){ + fail(svpx); + } + return null; + } +} diff --git a/src/test/java/org/tudo/sse/semver/SemanticVersionNumberTest.java b/src/test/java/org/tudo/sse/semver/SemanticVersionNumberTest.java new file mode 100644 index 0000000..3e926c4 --- /dev/null +++ b/src/test/java/org/tudo/sse/semver/SemanticVersionNumberTest.java @@ -0,0 +1,68 @@ +package org.tudo.sse.semver; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SemanticVersionNumberTest { + + private final String[] validNumbersSimple = new String[]{"1.0.0", "3.0.0", "1.2.0", "1.1.1", "1054.20000.1"}; + private final int[] validNumbersSimple_Rank = new int[] {0, 3, 2, 1, 4}; + + private final String[] preReleasesSimple = new String[]{"1.0.0", "1.0.0-gamma-1", "1.0.0-beta", "1.0.0-alpha-1"}; + private final int[] preReleasesSimple_Rank = new int[] {3, 2, 1, 0}; + + // This test case is taken from: https://semver.org/#spec-item-11 + private final String[] preReleaseParts = new String[]{"1.0.0", "1.0.0-rc.1", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-beta", "1.0.0-alpha.beta", "1.0.0-alpha.1", "1.0.0-alpha"}; + private final int[] preReleaseParts_Rank = new int[] {7,6,5,4,3,2,1,0}; + + @Test + @DisplayName("Simple version numbers must be sorted correctly") + public void sortSimple(){ + assertSorted(validNumbersSimple, validNumbersSimple_Rank); + } + + @Test + @DisplayName("Prereleases must be sorted correctly") + public void sortPrereleases(){ + assertSorted(preReleasesSimple, preReleasesSimple_Rank); + } + + @Test + @DisplayName("Prerelease parts must be parsed and sorted correctly") + public void sortPrereleaseParts(){ + assertSorted(preReleaseParts, preReleaseParts_Rank); + } + + private void assertSorted(String[] numbers, int[] ranks){ + var actual = sortSemantic(numbers); + + for(int i = 0; i < numbers.length; i++){ + var expectedNum = numbers[i]; + var expectedPos = ranks[i]; + + assertEquals(expectedNum, actual.get(expectedPos).toString()); + } + } + + private List sortSemantic(String[] numbers) { + List list = new ArrayList<>(); + + for(String number : numbers){ + var semVerOpt = SemanticVersionNumber.tryParse(number); + assert(semVerOpt.isPresent()); + list.add(semVerOpt.get()); + } + + Collections.sort(list); + + return list; + } + +} diff --git a/src/test/java/org/tudo/sse/semver/SemanticVersionParserTest.java b/src/test/java/org/tudo/sse/semver/SemanticVersionParserTest.java new file mode 100644 index 0000000..b6a0071 --- /dev/null +++ b/src/test/java/org/tudo/sse/semver/SemanticVersionParserTest.java @@ -0,0 +1,74 @@ +package org.tudo.sse.semver; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class SemanticVersionParserTest { + + private final String[] invalidSyntaxNumbers = new String []{"1a.2.3", "1.2.3.4", "foo", "1+a-n+c", "1..2", "1.0.0-"}; + private final String integerOverflow = "2131231231231231231231231231231231231231231231.1"; + + private final String[] validNumbersSimple = new String[]{"1.0.0", "3.0.0", "1.2.0", "1.1.1", "1054.20000.1"}; + private final int[][] validNumbersSimple_Expected = new int[][]{new int[]{1,0,0}, new int[]{3,0,0}, new int[]{1,2,0}, new int[]{1,1,1}, new int[]{1054,20000,1}}; + + private final String[] validNumbersComplex = new String[]{"1-a-valid-prerelase123+000000", "1.2.3+0a-def-----", "12-0+1", "0.0.0-0+0"}; + private final int[][] validNumbersComplex_Expected_Numbers = new int[][]{new int[]{1,0,0}, new int[]{1,2,3}, new int[]{12,0,0}, new int[]{0,0,0}}; + private final String[][] validNumbersComplex_Expected_Data = new String[][]{new String[]{"a-valid-prerelase123", "000000"}, new String[]{null, "0a-def-----"}, new String[]{"0","1"}, new String[]{"0", "0"}}; + + @Test + @DisplayName("Invalid syntax should lead to parsing exceptions") + public void parseInvalidSyntax(){ + for(String invalidSyntaxNumber: invalidSyntaxNumbers){ + assertThrows(SemanticVersionParsingException.class, () -> SemanticVersionNumber.parse(invalidSyntaxNumber)); + } + } + + @Test + @DisplayName("Integer overflows should lead to parsing exceptions") + public void parseOverflow(){ + assertThrows(SemanticVersionParsingException.class, () -> SemanticVersionNumber.parse(integerOverflow)); + } + + @Test + @DisplayName("Simple numbers should be parsed without exception") + public void parseSimpleNumbers(){ + try { + for(int i = 0; i < validNumbersSimple.length; i++){ + var semVer = SemanticVersionNumber.parse(validNumbersSimple[i]); + var expected = validNumbersSimple_Expected[i]; + + assertEquals(expected[0], semVer.getMajorVersion()); + assertEquals(expected[1], semVer.getMinorVersion()); + assertEquals(expected[2], semVer.getPatchVersion()); + } + } catch (SemanticVersionParsingException svpx) { + fail(svpx); + } + } + + @Test + @DisplayName("Complex numbers should be parsed without exception") + public void parseComplexNumbers(){ + try { + for(int i = 0; i < validNumbersComplex.length; i++){ + var semVer = SemanticVersionNumber.parse(validNumbersComplex[i]); + var expectedNumbers = validNumbersComplex_Expected_Numbers[i]; + var expectedData = validNumbersComplex_Expected_Data[i]; + + assertEquals(expectedNumbers[0], semVer.getMajorVersion()); + assertEquals(expectedNumbers[1], semVer.getMinorVersion()); + assertEquals(expectedNumbers[2], semVer.getPatchVersion()); + + assertEquals(expectedData[0], semVer.getPreRelease()); + assertEquals(expectedData[1], semVer.getBuildMetadata()); + } + } catch(SemanticVersionParsingException svpx){ + fail(svpx); + } + } + +}