diff --git a/base-option/pom.xml b/base-option/pom.xml index e3acc24..b190841 100644 --- a/base-option/pom.xml +++ b/base-option/pom.xml @@ -17,11 +17,6 @@ Type-safe Option types for configuration and parameter binding. - - build.base - base-foundation - - build.base base-configuration diff --git a/base-option/src/main/java/build/base/option/JDKVersion.java b/base-option/src/main/java/build/base/option/JDKVersion.java index f111c76..4e346c6 100644 --- a/base-option/src/main/java/build/base/option/JDKVersion.java +++ b/base-option/src/main/java/build/base/option/JDKVersion.java @@ -9,9 +9,9 @@ * Licensed 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. @@ -22,25 +22,29 @@ import build.base.configuration.Default; import build.base.configuration.Option; -import build.base.foundation.Strings; import java.io.DataInputStream; import java.io.InputStream; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.NoSuchElementException; -import java.util.Objects; import java.util.Optional; -import java.util.stream.Stream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - * Provides the ability to parse Java Development Kit (JDK) version numbers as defined by the - * Legacy Version - * specification (prior to Java 9) and the new - * Modern Version - * specification (from version 9 onwards), representing them as required by the new - * Versioning Scheme, including dropping the 1 for legacy versions - * when encountered. + * Parses Java Development Kit (JDK) version numbers. + *

+ * Modern version strings (Java 9 and later, per {@link Runtime.Version}) are handed straight to + * {@link Runtime.Version#parse(String)}, which also provides the ordering via {@link #compareTo}. + * Legacy strings (the pre-Java-9 + * scheme, + * e.g. {@code 1.8.0_292-b10} or {@code 1.7.0-ea-b19}) are first normalized into the modern + * JEP 223 form — the leading {@code 1.} is + * dropped, the legacy {@code _security} update is folded into the dotted version number (so + * {@code 1.8.0_292} becomes {@code 8.0.292}), and {@code -bNN} becomes {@code +NN}. + *

+ * Note that {@link #toString()} returns this normalized, modern-scheme form rather than echoing + * the string passed to {@link #of(String)}; use {@link #get()} to recover the raw input. * * @author brian.oliver * @since Nov-2019 @@ -49,73 +53,26 @@ public final class JDKVersion implements Option, Comparable { /** - * The raw version {@link String}. - */ - private final String rawVersion; - - /** - * The major version number. - */ - private final int major; - - /** - * The minor version number. - */ - private final int minor; - - /** - * The security version number. + * Matches a legacy (pre-Java-9) version string such as {@code 1.8.0_292-b10}, {@code 1.9.0-b100}, + * or {@code 1.7.0-ea-b19}, capturing (1) feature, (2) minor, (3) security, (4) build, (5) pre-release, + * and (6) build-after-pre-release. */ - private final int security; + private static final Pattern LEGACY = Pattern.compile( + "1\\.(\\d+)(?:\\.(\\d+))?(?:_(\\d+))?(?:-(?:b(\\d+)|([A-Za-z0-9]+)(?:-b(\\d+))?))?"); /** - * The build number, {@code null} if not present. + * The raw version {@link String} as supplied to the factory method. */ - private final Integer build; - - /** - * The pre-release information, {@code null} if not present. - */ - private final String prerelease; - - /** - * The version identifier, {@code null} if not present. - */ - private final String identifier; - - /** - * The numbers, in order of appearance, in the {@link JDKVersion}. - */ - private final List numbers; + private final String rawVersion; /** - * Constructs a {@link JDKVersion}. - * - * @param rawVersion the raw version {@link String} from which the {@link JDKVersion} was parsed - * @param major the major version - * @param minor the minor version - * @param security the security version - * @param build the {@code null}able build number - * @param prerelease the {@code null}able pre-release - * @param numbers the {@code null}able numbers in the {@link JDKVersion} + * The parsed, modern-scheme version this instance delegates to. */ - private JDKVersion(final String rawVersion, - final int major, - final int minor, - final int security, - final Integer build, - final String prerelease, - final String identifier, - final Stream numbers) { + private final Runtime.Version version; + private JDKVersion(final String rawVersion, final Runtime.Version version) { this.rawVersion = rawVersion; - this.major = major; - this.minor = minor; - this.security = security; - this.build = build; - this.prerelease = prerelease; - this.identifier = identifier; - this.numbers = numbers.toList(); + this.version = version; } /** @@ -128,30 +85,30 @@ public String get() { } /** - * Obtains the major version number. + * Obtains the feature (major) version number, e.g. {@code 25} for {@code 25.0.4}. * - * @return the major version number + * @return the feature version number */ public int major() { - return this.major; + return this.version.feature(); } /** - * Obtains the minor version number. + * Obtains the interim (minor) version number. * - * @return the minor version number + * @return the interim version number */ public int minor() { - return this.minor; + return this.version.interim(); } /** - * Obtains the security version number. + * Obtains the update (security) version number. * - * @return the security version number + * @return the update version number */ public int security() { - return this.security; + return this.version.update(); } /** @@ -160,7 +117,7 @@ public int security() { * @return the {@link Optional} build number */ public Optional build() { - return Optional.ofNullable(this.build); + return this.version.build(); } /** @@ -169,25 +126,36 @@ public Optional build() { * @return the {@link Optional} pre-release information */ public Optional pre() { - return Optional.ofNullable(this.prerelease); + return this.version.pre(); } /** - * Obtains the {@link Optional} identifier information. + * Obtains the {@link Optional} optional (additional build) information. * - * @return the {@link Optional} identifier information + * @return the {@link Optional} optional information */ public Optional optional() { - return Optional.ofNullable(this.identifier); + return this.version.optional(); + } + + /** + * Obtains the underlying {@link Runtime.Version} this instance delegates to. + * + * @return the {@link Runtime.Version} + */ + public Runtime.Version runtimeVersion() { + return this.version; } /** - * Obtains an unmodifiable {@link List} of the integer numerals contained in the {@link JDKVersion}. + * Obtains an unmodifiable {@link List} of the integers making up the version number (the + * dotted sequence before any {@code -} or {@code +}). The build number is not included; + * obtain it via {@link #build()}. * - * @return a {@link List} of {@link Integer} numerals in the {@link JDKVersion} + * @return a {@link List} of {@link Integer} numerals */ public List version() { - return this.numbers; + return this.version.version(); } /** @@ -196,332 +164,148 @@ public List version() { * @return {@code true} if the {@link JDKVersion} is modular, {@code false} otherwise */ public boolean isModular() { - return this.major >= 9; + return this.version.feature() >= 9; } @Override public boolean equals(final Object object) { - if (this == object) { - return true; - } - - if (object == null || getClass() != object.getClass()) { - return false; - } - - final var other = (JDKVersion) object; - - return this.major == other.major && - this.minor == other.minor && - this.security == other.security && - Objects.equals(this.build, other.build) && - Objects.equals(this.prerelease, other.prerelease) && - Objects.equals(this.identifier, other.identifier) && - this.numbers.equals(other.numbers); + return object instanceof JDKVersion other && compareTo(other) == 0; } @Override public int hashCode() { - return Objects.hash( - this.major, this.minor, this.security, this.build, this.prerelease, this.identifier, this.numbers); + // consistent with equals(), which is defined as compareTo() == 0: Runtime.Version.compareTo() + // and Runtime.Version.hashCode() take the same four components (version, pre, build, optional) + // into account, so equal instances necessarily hash alike + return this.version.hashCode(); } @Override public String toString() { - final var builder = new StringBuilder(); - builder.append(this.major); - - // output the other numbers except the build number when there are positive numbers following - final var size = this.build == null ? this.numbers.size() : this.numbers.size() - 1; - for (int i = 1; i < size; i++) { - final int number = this.numbers.get(i); - - // is a positive number following this number? - var positiveFollows = false; - for (int j = i + 1; j < size && !positiveFollows; j++) { - positiveFollows = this.numbers.get(j) > 0; - } - - if (number > 0 || positiveFollows) { - builder.append("."); - builder.append(number); - } - } - - if (this.prerelease != null) { - builder.append("-"); - builder.append(this.prerelease); - } - - if (this.build != null) { - builder.append("+"); - builder.append(this.build); - } - - if (this.identifier != null) { - - if (this.prerelease == null && this.build == null) { - builder.append("+"); - } - - builder.append("-"); - builder.append(this.identifier); - } - - return builder.toString(); + return this.version.toString(); } @Override public int compareTo(final JDKVersion other) { - // determine the smallest number of numbers from this and the other version - final var thisSize = other.numbers.size(); - final var otherSize = this.numbers.size(); - final var size = Math.min(thisSize, otherSize); - - // compare those numbers - for (int i = 0; i < size; i++) { - final var thisNumber = this.numbers.get(i); - final var otherNumber = other.numbers.get(i); - if (thisNumber != otherNumber) { - return thisNumber - otherNumber; - } - } - return thisSize - otherSize; + return this.version.compareTo(other.version); } /** - * Obtains the {@link JDKVersion} based on the {@code java.version} system property. + * Obtains the {@link JDKVersion} of the running JVM. * * @return the current {@link JDKVersion} */ @Default public static JDKVersion current() { - return of(System.getProperty("java.version")); + return new JDKVersion(Runtime.version().toString(), Runtime.version()); } /** - * Creates a {@link JDKVersion} for the specified major version number. + * Creates a {@link JDKVersion} for the specified feature (major) version number. *

- * For example, {@code JDKVersion.of(8)} produces a {@link JDKVersion} with a major version of 8. + * For example, {@code JDKVersion.of(8)} produces a {@link JDKVersion} with a feature version of 8. * - * @param major the major {@link JDKVersion} number + * @param major the feature {@link JDKVersion} number * @return a {@link JDKVersion} */ public static JDKVersion of(final int major) { - - final var list = new ArrayList(3); - list.add(major); - list.add(0); - list.add(0); - - final String rawVersion; - if (major < 9) { - // create a legacy version - rawVersion = "1." + major + ".0"; - } - else { - // create a modern version - rawVersion = major + ".0.0"; - } - - return new JDKVersion(rawVersion, major, 0, 0, null, null, null, list.stream()); + final var raw = Integer.toString(major); + return new JDKVersion(raw, Runtime.Version.parse(raw)); } /** - * Obtains the {@link JDKVersion} by parsing the specified {@link String}. + * Obtains the {@link JDKVersion} by parsing the specified {@link String}, accepting both the + * modern ({@link Runtime.Version}) and legacy (pre-Java-9) schemes. * * @param version the version * @return a {@link JDKVersion} - * @throws NoSuchElementException should the version be invalid format + * @throws IllegalArgumentException if the version is {@code null}, blank, or cannot be parsed */ public static JDKVersion of(final String version) { - if (Strings.isEmpty(version)) { + if (version == null || version.isBlank()) { throw new IllegalArgumentException("The specified version was empty or null"); } final var rawVersion = version.trim(); try { - final var numbers = new ArrayList(); - - var position = 0; - - final var majorString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Major Version Number ([0-9]+)")); - - position += majorString.length(); - - int major = Integer.parseInt(majorString); - int minor = 0; - int security = 0; - Integer build = null; - String prerelease = null; - String identifier = null; - - if (major == 1) { - // parse the legacy (non-modular) version specification (prior to version 9) - // according to https://www.oracle.com/technetwork/java/javase/versioning-naming-139433.html - - if (Strings.follows(rawVersion, position, ".")) { - position++; - - // for legacy versions, the minor version becomes the major version - final var legacyMajorString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Major Version Number ([0-9]+)")); - - position += legacyMajorString.length(); - major = Integer.parseInt(legacyMajorString); - } - numbers.add(major); - - if (Strings.follows(rawVersion, position, ".")) { - position++; - - final var minorString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Minor Version Number ([0-9]+)")); - - position += minorString.length(); - minor = Integer.parseInt(minorString); - } - numbers.add(minor); - - if (Strings.follows(rawVersion, position, "_")) { - position++; - - final var securityString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Security Number ([0-9]+)")); - - position += securityString.length(); - security = Integer.parseInt(securityString); - } - numbers.add(security); - - if (Strings.follows(rawVersion, position, "-b") - && Strings.follows(rawVersion, position + 2, Character::isDigit)) { - - position += 2; - final var buildString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Build Number ([0-9]+)")); - - position += buildString.length(); - build = Integer.parseInt(buildString); - numbers.add(build); - } - else if (Strings.follows(rawVersion, position, "-") - && Strings.follows(rawVersion, position + 1, - Character::isLetterOrDigit)) { + return new JDKVersion(rawVersion, Runtime.Version.parse(normalize(rawVersion))); + } + catch (final RuntimeException e) { + throw new IllegalArgumentException("Failed to parse version: " + rawVersion, e); + } + } - position += 1; + /** + * Normalizes an arbitrary JDK version string into a form {@link Runtime.Version#parse(String)} + * accepts: legacy (pre-Java-9) {@code 1.x} strings are remapped to the modern scheme, and + * trailing zero elements of the version number (which {@link Runtime.Version} rejects but the + * legacy scheme and real-world tooling emit, e.g. {@code 9.0.0}) are dropped. + * + * @param raw the raw version string + * @return the normalized, modern-scheme version string + */ + private static String normalize(final String raw) { + return stripTrailingZeroElements(raw.startsWith("1.") ? remapLegacy(raw) : raw); + } - prerelease = Strings.collectWhile(rawVersion, position, Character::isLetterOrDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Prerelease Number ([a-zA-Z0-9]+)")); + /** + * Remaps a legacy {@code 1.x} version string to the modern scheme, dropping the leading + * {@code 1.} and translating {@code _security} to {@code .security} and {@code -bNN} to + * {@code +NN}. Strings that do not match the legacy shape are returned unchanged. + */ + private static String remapLegacy(final String raw) { - position += prerelease.length(); + final Matcher matcher = LEGACY.matcher(raw); + if (!matcher.matches()) { + return raw; + } - if (Strings.follows(rawVersion, position, "-b") - && Strings.follows(rawVersion, position + 2, Character::isDigit)) { + final int feature = Integer.parseInt(matcher.group(1)); + final Integer minor = matcher.group(2) == null ? null : Integer.valueOf(matcher.group(2)); + final Integer security = matcher.group(3) == null ? null : Integer.valueOf(matcher.group(3)); + final String pre = matcher.group(5); + final String build = matcher.group(4) != null ? matcher.group(4) : matcher.group(6); - position += 2; - final var buildString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Build Number ([0-9]+)")); + final var builder = new StringBuilder().append(feature); + if (security != null) { + builder.append('.').append(minor == null ? 0 : minor).append('.').append(security); + } + else if (minor != null && minor != 0) { + builder.append('.').append(minor); + } + if (pre != null) { + builder.append('-').append(pre); + } + if (build != null) { + builder.append('+').append(Integer.parseInt(build)); + } + return builder.toString(); + } - position += buildString.length(); - build = Integer.parseInt(buildString); - numbers.add(build); - } - } - } - else { - // parse the new version specification (as of version 9) - // according to https://docs.oracle.com/javase/9/docs/api/java/lang/Runtime.Version.html - - // include the major version as the first number - numbers.add(major); - - // parse an arbitrary number parse digits - while (Strings.follows(rawVersion, position, ".")) { - position++; - - final var numberString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Number ([0-9]+)")); - - position += numberString.length(); - final var number = Integer.parseInt(numberString); - numbers.add(number); - } - - // the number after the major is the minor version - if (numbers.size() > 1) { - minor = numbers.get(1); - } - else { - numbers.add(minor); - } - - // the number after the minor is the security version - if (numbers.size() > 2) { - security = numbers.get(2); - } - else { - numbers.add(security); - } - - if (Strings.follows(rawVersion, position, "-")) { - position++; - - prerelease = Strings.collectWhile(rawVersion, position, Character::isLetterOrDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Prerelease Number ([a-zA-Z0-9]+)")); - - position += prerelease.length(); - - if (Strings.follows(rawVersion, position, "-")) { - identifier = Strings - .collectWhile(rawVersion, position, c -> Character.isLetterOrDigit(c) || c == '-') - .orElseThrow( - () -> new IllegalArgumentException("Expected Identifier Number ([-a-zA-Z0-9]+)")); - - return new JDKVersion(rawVersion, major, minor, security, build, prerelease, identifier, - numbers.stream()); - } - } - - if (Strings.follows(rawVersion, position, "+")) { - position++; - - final var buildString = Strings.collectWhile(rawVersion, position, Character::isDigit) - .orElseThrow(() -> new IllegalArgumentException("Expected Build Number ([0-9]+)")); - - position += buildString.length(); - build = Integer.parseInt(buildString); - numbers.add(build); - - if (Strings.follows(rawVersion, position, "-")) { - identifier = Strings - .collectWhile(rawVersion, position, c -> Character.isLetterOrDigit(c) || c == '-') - .orElseThrow( - () -> new IllegalArgumentException("Expected Identifier Number ([-a-zA-Z0-9]+)")); - } - - return new JDKVersion(rawVersion, major, minor, security, build, prerelease, identifier, - numbers.stream()); - } - - if (Strings.follows(rawVersion, position, "+-")) { - position++; - - identifier = Strings - .collectWhile(rawVersion, position, c -> Character.isLetterOrDigit(c) || c == '-') - .orElseThrow( - () -> new IllegalArgumentException("Expected Identifier Number ([-a-zA-Z0-9]+)")); - } + /** + * Drops trailing {@code .0} elements from the version-number portion (before any {@code -} or + * {@code +}), keeping at least the feature element, since {@link Runtime.Version} forbids them. + */ + private static String stripTrailingZeroElements(final String version) { + + int cut = version.length(); + for (int i = 0; i < version.length(); i++) { + final char c = version.charAt(i); + if (c == '-' || c == '+') { + cut = i; + break; } - - return new JDKVersion(rawVersion, major, minor, security, build, prerelease, identifier, numbers.stream()); } - catch (final Exception e) { - throw new RuntimeException("Failed to parse version: " + rawVersion, e); + + final String[] elements = version.substring(0, cut).split("\\.", -1); + int end = elements.length; + while (end > 1 && elements[end - 1].equals("0")) { + end--; } + + return String.join(".", Arrays.copyOf(elements, end)) + version.substring(cut); } /** diff --git a/base-option/src/main/java/module-info.java b/base-option/src/main/java/module-info.java index 3bf0032..00b371b 100644 --- a/base-option/src/main/java/module-info.java +++ b/base-option/src/main/java/module-info.java @@ -25,7 +25,6 @@ */ module build.base.option { requires transitive build.base.configuration; - requires build.base.foundation; exports build.base.option; } diff --git a/base-option/src/test/java/build/base/option/JDKVersionTests.java b/base-option/src/test/java/build/base/option/JDKVersionTests.java index c03d19c..3d4ae01 100644 --- a/base-option/src/test/java/build/base/option/JDKVersionTests.java +++ b/base-option/src/test/java/build/base/option/JDKVersionTests.java @@ -8,6 +8,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -43,6 +44,7 @@ static Stream jdkVersions() { jdkVersion("9.0.1+20", "9.0.1+20", 9, 0, 1, 20, null, true), jdkVersion("9.1.2", "9.1.2", 9, 1, 2, null, null, true), jdkVersion("9.1.2+1", "9.1.2+1", 9, 1, 2, 1, null, true), + jdkVersion("25.0.4.1", "25.0.4.1", 25, 0, 4, null, null, true), jdkVersion("9.1.3+15", "9.1.3+15", 9, 1, 3, 15, null, true)); } @@ -153,6 +155,17 @@ void shouldCompareVersionNumbers() { assertThat(JDKVersion.of("1.2")) .isLessThan(JDKVersion.of("1.2.1")); + // a shorter version compares equal to the same version zero-padded, but below one with a + // positive trailing segment (e.g. an Azul $PATCH respin) + assertThat(JDKVersion.of("25.0.4")) + .isEqualByComparingTo(JDKVersion.of("25.0.4.0")); + + assertThat(JDKVersion.of("25.0.4")) + .isLessThan(JDKVersion.of("25.0.4.1")); + + assertThat(JDKVersion.of("25.0.4.1")) + .isGreaterThan(JDKVersion.of("25.0.4")); + assertThat(JDKVersion.of("1.2")) .isLessThan(JDKVersion.of("1.3")); @@ -166,6 +179,89 @@ void shouldCompareVersionNumbers() { .isLessThan(JDKVersion.of("10")); } + /** + * Ensure the optional (additional build) information is exposed. + */ + @Test + void shouldExposeOptionalInformation() { + assertThat(JDKVersion.of("9.0.1+20-internal").optional()) + .contains("internal"); + + assertThat(JDKVersion.of("9.0.1+20").optional()) + .isEmpty(); + } + + /** + * Ensure {@link JDKVersion#version()} exposes the dotted version-number elements only, without + * the build number, and irrespective of the input scheme. + */ + @Test + void shouldExposeVersionNumberElements() { + assertThat(JDKVersion.of("9.0.1+20").version()) + .containsExactly(9, 0, 1); + + assertThat(JDKVersion.of("1.9.0-b100").version()) + .containsExactly(9); + + assertThat(JDKVersion.of("25.0.4.1").version()) + .containsExactly(25, 0, 4, 1); + + assertThat(JDKVersion.of("1.8.0_292-b10").version()) + .containsExactly(8, 0, 292); + } + + /** + * Ensure {@link JDKVersion#get()} echoes the raw input while {@link JDKVersion#toString()} + * returns the normalized, modern-scheme form. + */ + @Test + void shouldRetainRawVersionSeparatelyFromToString() { + final JDKVersion version = JDKVersion.of("1.8.0_292-b10"); + + assertThat(version.get()) + .isEqualTo("1.8.0_292-b10"); + + assertThat(version.toString()) + .isEqualTo("8.0.292+10"); + } + + /** + * Ensure {@link JDKVersion#equals(Object)} and {@link JDKVersion#hashCode()} stay consistent, + * including when two instances differ only in the optional (additional build) information or in + * trailing zero elements. + */ + @Test + void shouldKeepEqualsAndHashCodeConsistent() { + final JDKVersion padded = JDKVersion.of("25.0.4.0"); + final JDKVersion unpadded = JDKVersion.of("25.0.4"); + + assertThat(padded) + .isEqualTo(unpadded) + .hasSameHashCodeAs(unpadded); + + final JDKVersion withOptional = JDKVersion.of("9.0.1+20-internal"); + final JDKVersion withoutOptional = JDKVersion.of("9.0.1+20"); + + assertThat(withOptional) + .isNotEqualTo(withoutOptional); + } + + /** + * Ensure invalid, blank, and {@code null} version strings are rejected with + * {@link IllegalArgumentException}. + */ + @Test + void shouldRejectInvalidVersionStrings() { + assertThatThrownBy(() -> JDKVersion.of((String) null)) + .isInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> JDKVersion.of(" ")) + .isInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> JDKVersion.of("not-a-version")) + .isInstanceOf(IllegalArgumentException.class); + } + /** * Ensure a {@link JDKVersion} can be created using just a major version number. */