VersionStrings.java

/**
 * Copyright (c) 2018 European Organisation for Nuclear Research (CERN), All Rights Reserved.
 */

package org.jmad.modelpack.util;

import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;

import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

import com.google.common.annotations.VisibleForTesting;

/**
 * Contains utility methods which can be used to treat strings that represent versions.
 * 
 * @author kfuchsbe
 */
public final class VersionStrings {

    private static final String VERSION_PREFIX = "v";
    private static final Comparator<String> NULLS_FIRST_VERSION_COMPARATOR = (String v1, String v2) -> {

        if (Objects.equals(v1, v2)) {
            return 0;
        } else if (Objects.isNull(v1)) {
            return -1;
        } else if (Objects.isNull(v2)) {
            return 1;
        }

        List<Integer> v1digits = versionDigits(v1);
        List<Integer> v2digits = versionDigits(v2);

        if (v1digits.isEmpty() && v2digits.isEmpty()) {
            return v1.compareToIgnoreCase(v2);
        } else if (v1digits.isEmpty()) {
            return -1;
        } else if (v2digits.isEmpty()) {
            return 1;
        }

        if (!hasVersionPrefix(v1) && hasVersionPrefix(v2)) {
            return -1;
        } else if (hasVersionPrefix(v1) && (!hasVersionPrefix(v2))) {
            return 1;
        }

        int smallestSize = Math.min(v1digits.size(), v2digits.size());
        for (int i = 0; i < smallestSize; i++) {
            if (v1digits.get(i) < v2digits.get(i)) {
                return -1;
            } else if (v1digits.get(i) > v2digits.get(i)) {
                return 1;
            }
        }

        if (v2digits.size() > smallestSize) {
            return -1;
        } else if (v1digits.size() > smallestSize) {
            return 1;
        }

        return v1.compareToIgnoreCase(v2);
    };

   
    private static boolean hasVersionPrefix(String v1) {
        return v1.trim().startsWith(VERSION_PREFIX);
    }

    private VersionStrings() {
        /* only static methods */
    }

    /**
     * Returns a comparator to be used for strings that represent software versions. The following format is
     * recommended: 'v10.2.1', whereas the number of digits can vary. The 'v' in the beginning is optional, but
     * recommended. However a version of e.g. 'v1.1' is not considered the same as a version of '1.1'. There must be no
     * space between the 'v' and the first digit. Leading or trailing spaces are ignored, also in between dots. However,
     * spaces anywhere in a version string are strongly discouraged.
     * <p>
     * If used for sorting, then the final order will follow the following rules:
     * <ol>
     * <li>{@code null} values</li>
     * <li>unparsable version numbers (e.g. containing other letters than a leading 'v' or no digits). These are sorted
     * internally by string natural order.</li>
     * <li>version numbers without a leading 'v' before version numbers with a leading 'v'. Both sorted internally
     * ascending from the most significant digit (left) to the least significant. If different numbers of digits are
     * used, the one with less digits are considered as missing digits at the end and ar considered as less then those
     * with digits at the end. For example, the versions 'v1.2', 'v1.1.0', 'v1.1' would be sorted as: [ 'v1.1',
     * 'v1.1.0', 'v1.2']. If the different strings result in the same numerical versions (e.g. 'v1.1' and ' v1.1', then
     * again natural string ordering is used.</li>
     * </ol>
     * 
     * @return a comparator following the described behaviour.
     */
    public static Comparator<String> versionComparator() {
        return NULLS_FIRST_VERSION_COMPARATOR;
    }

    /**
     * Considers strings as version strings and splits them into their digits. The first entry in the list will be the
     * most significant digit, the next one the one less significant digit. Whenever any digit cannot be parsed (the
     * string is invalid), then an empty list is returned. The only letter which is allowed in the input string is a
     * leading {@value #VERSION_PREFIX}. If this is present, it will be ignored by this method. Whitespaces are ignored.
     * 
     * @param versionString the string to be transformed into integer digits
     * @return a list, containing the digits (most significant first), or an empty list, if the given string does not
     *         represent a valid version.
     * @throws NullPointerException in case the given string is {@code null}
     */
    @VisibleForTesting
    static List<Integer> versionDigits(String versionString) {
        requireNonNull(versionString, "versionString must not be null");

        String trimmed = versionString.trim();
        if (hasVersionPrefix(trimmed)) {
            trimmed = trimmed.substring(VERSION_PREFIX.length());
        }
        try {
            return Stream.of(trimmed.split("\\.")).map(String::trim).map(Integer::parseInt).collect(toList());
        } catch (NumberFormatException e) {
            return Collections.emptyList();
        }
    }

}