org.cthing.versionparser.maven.MvnVersion Maven / Gradle / Ivy
Show all versions of versionparser Show documentation
/*
* Copyright 2023 C Thing Software
* SPDX-License-Identifier: Apache-2.0
*
* This file is derived from org.eclipse.aether.util.version.GenericVersion.java
* which is covered by the following copyright and permission notices:
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.cthing.versionparser.maven;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.cthing.versionparser.AbstractVersion;
import org.cthing.versionparser.Version;
import org.jspecify.annotations.Nullable;
/**
* Represents the version of an artifact in the Maven ecosystem. To obtain
* an instance of this class, call the {@link MvnVersionScheme#parseVersion(String)} method.
*
* Versions are interpreted as a sequence of numeric and alphabetic components. The characters '-', '_', and '.' as
* well as the transitions from digit to letter and vice versa delimit the version components. Delimiters are
* considered equivalent.
*
*
* Numeric components are compared mathematically. Alphabetic components are treated as case-insensitive and compared
* lexicographically. However, the following qualifier strings are treated specially with the following ordering:
* {@code alpha}/{@code a} < {@code beta}/{@code b} < {@code milestone}/{@code m} < {@code cr}/{@code rc}
* < {@code snapshot} < {@code final}/{@code ga} < {@code sp}. Each of these
* well-known qualifiers are considered smaller/older than other strings. An empty component or string is equivalent to
* 0.
*
*
* In addition to the above mentioned qualifiers, the tokens {@code min} and {@code max} may be used as the last
* version component to denote the smallest/greatest version having a given prefix. For example, "1.2.min" denotes
* the smallest version in the 1.2 line, and "1.2.max" denotes the greatest version in the 1.2 line.
*
*
* Numbers and strings are considered incomparable when compared against each other. Where version components of
* a different kind collide, comparison assumes that the previous components are padded with trailing 0 or "ga"
* components, respectively, until the mismatch is resolved (e.g. "1-alpha" = "1.0.0-alpha" < "1.0.1-ga" = "1.0.1").
*
*/
public final class MvnVersion extends AbstractVersion {
private final List components;
private final boolean preRelease;
/**
* Creates a version instance.
*
* @param version Original version string
* @param components Individual items that make up the version
*/
private MvnVersion(final String version, final List components) {
super(version);
this.components = components;
this.preRelease = components.stream()
.filter(Component::isQualifier)
.mapToInt(component -> (int)component.value())
.anyMatch(qualifier -> qualifier != Tokenizer.QUALIFIER_RELEASE
&& qualifier != Tokenizer.QUALIFIER_SP);
}
/**
* Provides the parsed components of the version.
*
* @return Parsed components of the version. Note that due to removal of padding and other substitutions,
* reconstructing the version from the components may not match the original specified version.
*/
public List getComponents() {
return this.components.stream().map(Component::toString).toList();
}
@Override
public boolean isPreRelease() {
return this.preRelease;
}
/**
* Parses the specified version string and returns a new instance of this class. To parse a version, call
* {@link MvnVersionScheme#parseVersion(String)}.
*
* @param version Version string to parse
* @return Version object
*/
static MvnVersion parse(final String version) {
final String trimmedVersion = version.trim();
final List comps = new ArrayList<>();
new Tokenizer(trimmedVersion).forEach(comps::add);
trimPadding(comps);
return new MvnVersion(trimmedVersion, Collections.unmodifiableList(comps));
}
/**
* Removes from the specified list of components all trailing zeroes and qualifiers that correspond to releases.
*
* @param comps Components of the version to be pruned of trailing zeroes and release qualifiers.
*/
private static void trimPadding(final List comps) {
Boolean number = null;
int end = comps.size() - 1;
for (int i = end; i > 0; i--) {
final Component comp = comps.get(i);
if (!Boolean.valueOf(comp.isNumber()).equals(number)) {
end = i;
number = comp.isNumber();
}
if (end == i && (i == comps.size() - 1 || comps.get(i - 1).isNumber() == comp.isNumber())
&& comp.compareTo(null) == 0) {
comps.remove(i);
end--;
}
}
}
@Override
public int compareTo(final Version obj) {
if (getClass() != obj.getClass()) {
throw new IllegalArgumentException("Expected instance of MvnVersion but received "
+ obj.getClass().getName());
}
final List those = ((MvnVersion)obj).components;
final List these = this.components;
boolean isNumber = true;
for (int index = 0; ; index++) {
if (index >= these.size() && index >= those.size()) {
return 0;
}
if (index >= these.size()) {
return -comparePadding(those, index, null);
}
if (index >= those.size()) {
return comparePadding(these, index, null);
}
final Component thisComponent = these.get(index);
final Component thatComponent = those.get(index);
if (thisComponent.isNumber() != thatComponent.isNumber()) {
return (isNumber == thisComponent.isNumber())
? comparePadding(these, index, isNumber)
: -comparePadding(those, index, isNumber);
}
final int rel = thisComponent.compareTo(thatComponent);
if (rel != 0) {
return rel;
}
isNumber = thisComponent.isNumber();
}
}
private static int comparePadding(final List comps, final int index, @Nullable final Boolean number) {
int rel = 0;
for (int i = index; i < comps.size(); i++) {
final Component comp = comps.get(i);
if (number != null && number != comp.isNumber()) {
break;
}
rel = comp.compareTo(null);
if (rel != 0) {
break;
}
}
return rel;
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return compareTo((Version)obj) == 0;
}
@Override
public int hashCode() {
return Objects.hashCode(this.components);
}
/**
* Performs the parsing of a version into components. The components are categorized whether they are
* numeric, string, or qualifier keyword.
*/
private static final class Tokenizer implements Iterable, Iterator {
private enum State {
INITIAL,
LETTER,
LEADING_ZERO,
REGULAR_DIGIT,
}
private record Token(String value, boolean isNumber, boolean terminatedByNumber) {
}
static final int QUALIFIER_ALPHA = -5;
static final int QUALIFIER_BETA = -4;
static final int QUALIFIER_MILESTONE = -3;
static final int QUALIFIER_RC = -2;
static final int QUALIFIER_SNAPSHOT = -1;
static final int QUALIFIER_RELEASE = 0;
static final int QUALIFIER_SP = 1;
private static final int MAX_INTEGER_CHARS = 10;
private static final Map QUALIFIERS = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
static {
QUALIFIERS.put("alpha", QUALIFIER_ALPHA);
QUALIFIERS.put("beta", QUALIFIER_BETA);
QUALIFIERS.put("milestone", QUALIFIER_MILESTONE);
QUALIFIERS.put("cr", QUALIFIER_RC);
QUALIFIERS.put("rc", QUALIFIER_RC);
QUALIFIERS.put("snapshot", QUALIFIER_SNAPSHOT);
QUALIFIERS.put("ga", QUALIFIER_RELEASE);
QUALIFIERS.put("final", QUALIFIER_RELEASE);
QUALIFIERS.put("release", QUALIFIER_RELEASE);
QUALIFIERS.put("", QUALIFIER_RELEASE);
QUALIFIERS.put("sp", QUALIFIER_SP);
}
private final String version;
private int index;
@Nullable private Token token;
Tokenizer(final String version) {
this.version = version.isEmpty() ? "0" : version;
}
@Override
public Iterator iterator() {
return this;
}
@Override
public boolean hasNext() {
final int n = this.version.length();
if (this.index >= n) {
return false;
}
State state = State.INITIAL;
int start = this.index;
int end = n;
boolean terminatedByNumber = false;
for ( ; this.index < n; this.index++) {
final char c = this.version.charAt(this.index);
if (c == '.' || c == '-' || c == '_') {
end = this.index;
this.index++;
break;
}
final int digit = Character.digit(c, 10);
if (digit >= 0) { // Character is a digit
// If we were seeing letters, we have a transition delimiter
if (state == State.LETTER) {
end = this.index;
terminatedByNumber = true;
break;
}
// If we are still seeing leading zeros, strip them so that Integer/BigInteger are happy.
if (state == State.LEADING_ZERO) {
start++;
}
state = (state == State.REGULAR_DIGIT || digit > 0) ? State.REGULAR_DIGIT : State.LEADING_ZERO;
} else { // Character is a letter
// If we were seeing digits, we have a transition delimiter
if (state == State.REGULAR_DIGIT || state == State.LEADING_ZERO) {
end = this.index;
break;
}
state = State.LETTER;
}
}
if (end - start > 0) {
final String value = this.version.substring(start, end);
final boolean isNumber = (state == State.REGULAR_DIGIT || state == State.LEADING_ZERO);
this.token = new Token(value, isNumber, terminatedByNumber);
} else {
this.token = new Token("0", true, terminatedByNumber);
}
return true;
}
@Override
public Component next() {
if (this.token == null) {
throw new IllegalStateException("token cannot be null");
}
if (this.token.isNumber) {
try {
return (this.token.value.length() < MAX_INTEGER_CHARS)
? new Component(Component.KIND_INT, Integer.valueOf(this.token.value), this.token.value)
: new Component(Component.KIND_BIGINT, new BigInteger(this.token.value), this.token.value);
} catch (final NumberFormatException ex) {
throw new IllegalStateException(ex);
}
}
if (this.index >= this.version.length()) {
if ("min".equalsIgnoreCase(this.token.value)) {
return Component.MIN;
}
if ("max".equalsIgnoreCase(this.token.value)) {
return Component.MAX;
}
}
if (this.token.terminatedByNumber && this.token.value.length() == 1) {
switch (this.token.value.charAt(0)) {
case 'a', 'A' -> {
return new Component(Component.KIND_QUALIFIER, QUALIFIER_ALPHA, this.token.value);
}
case 'b', 'B' -> {
return new Component(Component.KIND_QUALIFIER, QUALIFIER_BETA, this.token.value);
}
case 'm', 'M' -> {
return new Component(Component.KIND_QUALIFIER, QUALIFIER_MILESTONE, this.token.value);
}
default -> {
}
}
}
final Integer qualifier = QUALIFIERS.get(this.token.value);
return (qualifier == null)
? new Component(Component.KIND_STRING, this.token.value.toLowerCase(Locale.ENGLISH), this.token.value)
: new Component(Component.KIND_QUALIFIER, qualifier, this.token.value);
}
@Override
public String toString() {
return (this.token == null) ? "" : this.token.value;
}
}
/**
* Represents a component of a version string. For example, the version "1.2.3", consists of three components "1",
* "2", and "3".
*
* @param kind Type of the component
* @param value Value of the component
* @param original Component as specific in the version
*/
private record Component(int kind, Object value, String original) implements Comparable {
static final int KIND_MAX = 8;
static final int KIND_BIGINT = 5;
static final int KIND_INT = 4;
static final int KIND_STRING = 3;
static final int KIND_QUALIFIER = 2;
static final int KIND_MIN = 0;
static final Component MAX = new Component(KIND_MAX, "max", "max");
static final Component MIN = new Component(KIND_MIN, "min", "min");
public boolean isNumber() {
return (this.kind & KIND_QUALIFIER) == 0; // i.e. kind != string/qualifier
}
public boolean isQualifier() {
return this.kind == KIND_QUALIFIER;
}
public boolean isEmpty() {
return switch (this.kind) {
case KIND_MAX, KIND_MIN -> false;
case KIND_BIGINT -> BigInteger.ZERO.equals(this.value);
case KIND_INT, KIND_QUALIFIER -> ((Integer)this.value) == 0;
case KIND_STRING -> ((CharSequence)this.value).isEmpty();
default -> throw new IllegalStateException("unknown version component kind " + this.kind);
};
}
@Override
@SuppressWarnings("OverlyStrongTypeCast")
public int compareTo(@Nullable final Component other) {
int rel;
if (other == null) {
// null in this context denotes the pad item (0 or "ga")
rel = switch (this.kind) {
case KIND_MIN -> -1;
case KIND_MAX, KIND_BIGINT, KIND_STRING -> 1;
case KIND_INT, KIND_QUALIFIER -> (Integer)this.value;
default -> throw new IllegalStateException("unknown version component kind " + this.kind);
};
} else {
rel = this.kind - other.kind;
if (rel == 0) {
rel = switch (this.kind) {
case KIND_MAX, KIND_MIN -> rel;
case KIND_BIGINT -> ((BigInteger)this.value).compareTo((BigInteger)other.value);
case KIND_INT, KIND_QUALIFIER -> ((Integer)this.value).compareTo((Integer)other.value);
case KIND_STRING -> ((String)this.value).compareToIgnoreCase((String)other.value);
default -> throw new IllegalStateException("unknown version component kind: " + this.kind);
};
}
}
return rel;
}
@Override
public String toString() {
return isQualifier() ? this.original : String.valueOf(this.value);
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return compareTo((Component)obj) == 0;
}
@Override
public int hashCode() {
return Objects.hash(this.kind, this.value);
}
}
}