URL,Version pair from an API call
+ * TODO: Make this more vendor-agnostic
+ */
+ private PairRuntime.Version
+ * and legacy "1.x" handling.
+ *
+ * + * This class aims to handle all historical and future nuances caused by Java's incompliance and inconsistencies despite the great unlikelihood of some of these + * past decisions ever being relevant in the future. + *
+ * + *
+ * Starting with Java 9, Runtime.Version is Java's officially supported way for handling Java version
+ * handling, but this library is not fully semver compliant as it prohibits certain items
+ * such as trailing zeros yet appends non-standard items such as "-LTS" to the build information. These discrepancies
+ * break predictability. For example "11.0.3+23-LTS" stores "+23" and "LTS" as
+ * separate build meta-data, which is equivalent to "11.0.3+23.LTS" (notice the "-" has been
+ * replaced with a "."), so we try to strip that off as it's irrelevant for semantic comparison. Additionally,
+ * Java historically has conflated certain numbering. For example, "Java 8" was internally known as "1.8.0"
+ * and would report as "1.8.0" so parsing "8.0" should always match "1.8.0" .
+ * Worse yet, the old build information was formatted "1.8.0_202" which can't be parsed by ANY semver library,
+ * so special considerations must be made to sanitize all possible input variants.
+ *
Runtime.version() as a semantic version
+ */
+ public static Version current() {
+ return toSemantic(Runtime.version());
+ }
+
+ /**
+ * Parses the input and converts to a semantic version. Allows both "bare" formatted data such as "11.0"
+ * as well as a "wall of text" such as that outputted by "java --version" and handles any sanitization
+ * thereof.
+ */
+ public static Version parse(String rawInput) {
+ String isolated = isolate(rawInput);
+ String sanitized = sanitize(isolated);
+ return parseStrict(sanitized);
+ }
+
+ /**
+ * Call a java command (e.g. java) with "--version" and parse the output
+ * The double dash "--" is since JDK9 but important to send the command output to stdout
+ */
+ public static Version parseCli(Path javaBin) {
+ return parse(ShellUtilities.executeRaw(javaBin.toString(), "--version"));
+ }
+
+ /**
+ * Parses an isolated and sanitized java version (e.g. "1.8" NOT "1.8.0") using
+ * a combination of Java's internal versioning and a dedicated semver library.
+ */
+ static Version parseStrict(String javaVersion) {
+ return toSemantic(Runtime.Version.parse(javaVersion));
+ }
+
+ /**
+ * Handle nuances with Java's semver reporting.
+ * e.g. Strip invalid "-LTS" suffix, coerce "Java 8 = 1.8.0", etc
+ */
+ static String sanitize(String fuzzyVersion) {
+ String sanitized = fuzzyVersion;
+
+ // Chomp off "-LTS"
+ if(sanitized.endsWith("-LTS")) {
+ sanitized = sanitized.substring(0, sanitized.length() - 4);
+ }
+ // Legacy formatting
+ // isolate first digit
+ String firstDigit = (sanitized.length() > 1 && sanitized.contains(".")) ? sanitized.split("\\.")[0] : sanitized;
+ switch(firstDigit) {
+ case "8":
+ case "7":
+ case "6":
+ case "5":
+ case "4":
+ case "3":
+ case "2":
+ sanitized = "1." + sanitized;
+ }
+
+ // Legacy versions: Replace "_" with "+", strip "-bNN" (e.g. "1.8.0_202-b08")
+ if(sanitized.startsWith("1.")) {
+ sanitized = sanitized.replaceFirst("_", "+");
+ if(sanitized.contains("-b")) {
+ sanitized = sanitized.split("-b", 2)[0];
+ }
+ }
+
+ // Java prohibits trailing zeros for no fricken reason
+ // We limit the split to 2 parts so we don't break this same rule in the metadata
+ String[] parts = sanitized.split("(?=[+\\-])", 2);
+ parts[0] = parts[0].replaceAll("(\\.0)+$", "");
+ return (parts.length > 1) ? parts[0] + parts[1] : parts[0];
+ }
+
+ /**
+ * Convert Runtime.Version to a semver-compatible Version
+ */
+ static Version toSemantic(Runtime.Version rv) {
+ Version converted = rv.pre().isPresent() ? Version.of(rv.feature(), rv.interim(), rv.update(), rv.pre().get()) :
+ Version.of(rv.feature(), rv.interim(), rv.update());
+
+ List"java --version"
+ */
+ static String isolate(String rawInput) {
+ // Try to find the "(build 11.0.27+0)" line
+ String buildMatch = "(build";
+ if(rawInput.contains("\n")) {
+ String[] lines = rawInput.split("\n");
+ for(String line : lines) {
+ int buildLoc = line.indexOf(buildMatch);
+ if(buildLoc != -1) {
+ rawInput = line.substring(buildLoc + buildMatch.length() , line.length() -1);
+ break;
+ }
+ }
+ }
+ // Chomp off leading "openjdk", etc
+ int i;
+ for(i = 0; i < rawInput.length() - 1; i++) {
+ if(ByteUtilities.isNumber(rawInput.substring(i, i+1))) {
+ rawInput = rawInput.substring(i).trim();
+ break;
+ }
+ }
+ // Chomp off any trailing data
+ if(rawInput.contains(" ")) {
+ rawInput = rawInput.split(" ", 2)[0];
+ }
+
+ return rawInput;
+ }
+}
diff --git a/src/qz/utils/ShellUtilities.java b/src/qz/utils/ShellUtilities.java
index a2693b85f..0b7f09534 100644
--- a/src/qz/utils/ShellUtilities.java
+++ b/src/qz/utils/ShellUtilities.java
@@ -336,4 +336,43 @@ public static String envpToString() {
}
return "(suppressed)";
}
+
+ /**
+ * Prints a pretty ASCII box on the screen
+ */
+ public static String consoleBox(String... lines) {
+ if (lines == null || lines.length == 0) return "";
+
+ int padding = 2; // Number of spaces on each side
+ int maxWidth = 0;
+
+ for (String s : lines) {
+ if (s != null && s.length() > maxWidth) {
+ maxWidth = s.length();
+ }
+ }
+
+ // Total internal width including padding
+ int totalWidth = maxWidth + (padding * 2);
+
+ StringBuilder sb = new StringBuilder("\n");
+
+ // Top border
+ sb.append("╔").append("═".repeat(totalWidth)).append("╗\n");
+
+ // Content lines
+ for (String s : lines) {
+ if (s == null) s = "";
+ int trailingSpaces = totalWidth - s.length() - padding;
+ sb.append("║")
+ .append(" ".repeat(padding)) // Left padding
+ .append(s)
+ .append(" ".repeat(trailingSpaces)) // Right padding to fill the box
+ .append("║\n");
+ }
+
+ // Bottom border
+ sb.append("╚").append("═".repeat(totalWidth)).append("╝").append("\n");
+ return sb.toString();
+ }
}
diff --git a/src/qz/utils/SystemUtilities.java b/src/qz/utils/SystemUtilities.java
index 7d39c2a7e..7553fc9bc 100644
--- a/src/qz/utils/SystemUtilities.java
+++ b/src/qz/utils/SystemUtilities.java
@@ -41,6 +41,7 @@
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
+import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
@@ -204,22 +205,10 @@ public static String whoami() {
return whoami;
}
- public static Version getJavaVersion() {
- return getJavaVersion(System.getProperty("java.version"));
- }
-
- /**
- * Call a java command (e.g. java) with "--version" and parse the output
- * The double dash "--" is since JDK9 but important to send the command output to stdout
- */
- public static Version getJavaVersion(Path javaCommand) {
- return getJavaVersion(ShellUtilities.executeRaw(javaCommand.toString(), "--version"));
- }
-
public static int getProcessId() {
if(pid == null) {
// Try Java 9+
- if(Constants.JAVA_VERSION.getMajorVersion() >= 9) {
+ if(Constants.JAVA_VERSION.majorVersion() >= 9) {
pid = getProcessIdJigsaw();
}
// Try JNA
@@ -246,47 +235,6 @@ private static int getProcessIdJigsaw() {
return -1;
}
- /**
- * Handle Java versioning nuances
- * To eventually be replaced with java.lang.Runtime.Version (JDK9+)
- */
- public static Version getJavaVersion(String version) {
- String[] parts = version.trim().split("\\D+");
-
- int major = 1;
- int minor = 0;
- int patch = 0;
- String meta = "";
-
- try {
- switch(parts.length) {
- default:
- case 4:
- meta = parts[3];
- case 3:
- patch = Integer.parseInt(parts[2]);
- case 2:
- minor = Integer.parseInt(parts[1]);
- major = Integer.parseInt(parts[0]);
- break;
- case 1:
- major = Integer.parseInt(parts[0]);
- if (major <= 8) {
- // Force old 1.x style formatting
- minor = major;
- major = 1;
- }
- }
- } catch(NumberFormatException e) {
- log.warn("Could not parse Java version \"{}\"", version, e);
- }
- if(meta.trim().isEmpty()) {
- return Version.of(major, minor, patch);
- } else {
- return Version.of(major, minor, patch, null, meta);
- }
- }
-
/**
* Determines the currently running Jar's absolute path on the local filesystem
* todo: make this return a sane directory for running via ide
diff --git a/test/qz/utils/JavaVersionTests.java b/test/qz/utils/JavaVersionTests.java
new file mode 100644
index 000000000..8e3d11724
--- /dev/null
+++ b/test/qz/utils/JavaVersionTests.java
@@ -0,0 +1,196 @@
+package qz.utils;
+
+import com.github.zafarkhaja.semver.Version;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import qz.build.JLink;
+
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.Properties;
+
+import static qz.utils.JavaVersion.*;
+
+public class JavaVersionTests {
+ private static final Logger log = LogManager.getLogger(JavaVersionTests.class);
+
+ @DataProvider(name = "versions")
+ public Object[][] versions() {
+ return new Object[][] {
+ // final, sanitized, isolated, raw input
+ {
+ "25.0.0+1",
+ "25+1",
+ "25+1",
+ "openjdk 25 2025-01-21\n" +
+ "OpenJDK Runtime Environment (build 25+1)\n" +
+ "OpenJDK 64-Bit Server VM (build 25+1, mixed mode, sharing)"
+ },
+ {
+ "25.0.2+12",
+ "25.0.2+12", // chomp off "-LTS"
+ "25.0.2+12-LTS",
+ "openjdk 25.0.2 2026-01-20 LTS\n" +
+ "OpenJDK Runtime Environment (build 25.0.2+12-LTS)\n" +
+ "OpenJDK 64-Bit Server VM (build 25.0.2+12-LTS, mixed mode, sharing)"
+ },
+ {
+ "11.0.27+0",
+ "11.0.27+0",
+ "11.0.27+0",
+ "openjdk 11.0.27 2025-04-15\n" +
+ "OpenJDK Runtime Environment Homebrew (build 11.0.27+0)\n" +
+ "OpenJDK 64-Bit Server VM Homebrew (build 11.0.27+0, mixed mode)"
+ },
+ {
+ "25.0.0",
+ "25",
+ "25",
+ "openjdk 25 2025-09-16\n" +
+ "OpenJDK Runtime Environment Homebrew (build 25)\n" +
+ "OpenJDK 64-Bit Server VM Homebrew (build 25, mixed mode, sharing)"
+ },
+ {
+ "11.0.4+10",
+ "11.0.4+10", // chomp off "-LTS"
+ "11.0.4+10-LTS",
+ "Picked up _JAVA_OPTIONS: - Xmx512M\n" +
+ "java version \"11.0.4\" 2019-07-16 LTS\n" +
+ "Java(TM) SE Runtime Environment 18.9 (build 11.0.4+10-LTS)\n" +
+ "Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.4+10-LTS, mixed mode)"
+ },
+ {
+ "1.8.0+202",
+ "1.8+202", // Runtime.Version doesn't permit trailing zeros
+ "1.8.0_202-b08",
+ "java version \"1.8.0_202\"\n" +
+ "Java(TM) SE Runtime Environment (build 1.8.0_202-b08)\n" +
+ "Java HotSpot (TM) 64-Bit Server VM (build 25.202-h08, mixed mode)"
+ },
+ {
+ "1.7.0+55",
+ "1.7+55", // Runtime.Version doesn't permit trailing zeros
+ "1.7.0_55-b13",
+ "java version \"1.7.0_55\"\n" +
+ "Java(TM) SE Runtime Environment (build 1.7.0_55-b13)\n" +
+ "Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)"
+ },
+ {
+ "27.0.0-ea+18.1643", // semver wants a dot not a dash :/
+ "27-ea+18-1643",
+ "27-ea+18-1643",
+ "openjdk 27-ea 2026-09-15\n" +
+ "OpenJDK Runtime Environment (build 27-ea+18-1643)\n" +
+ "OpenJDK 64-Bit Server VM (build 27-ea+18-1643, mixed mode, sharing)"
+ },
+ {
+ "17.0.9+9.jvmci-23.0-b22", // semver wants a dot not a dash :/
+ "17.0.9+9-jvmci-23.0-b22",
+ "17.0.9+9-jvmci-23.0-b22",
+ "openjdk version \"17.0.9\" 2023-10-17\n" +
+ "OpenJDK Runtime Environment GraalVM CE 17.0.9+9.1 (build 17.0.9+9-jvmci-23.0-b22)\n" +
+ "OpenJDK 64-Bit Server VM GraalVM CE 17.0.9+9.1 (build 17.0.9+9-jvmci-23.0-b22, mixed mode, sharing)"
+ },
+ // Java 8 was actually Java 1.8.0
+ {
+ "1.8.0",
+ "1.8",
+ "8",
+ "8"
+ }
+ };
+ }
+
+ /**
+ * Test stdout values from java --version
+ */
+ @Test(dataProvider = "versions")
+ public void rawInputTests(String finalExpected, String sanitizedExpected, String isolatedExpected, String rawInput) {
+ // Ensure we can isolate "11.0.3" from "(build 11.0.3)"
+ String isolatedActual = isolate(rawInput);
+ log.trace("Comparing isolated values: '{}' : '{}'", isolatedExpected, isolatedActual);
+ Assert.assertEquals(isolatedExpected, isolatedActual);
+
+ // Sanitize the value to confirm with Runtime.Version parsing
+ String sanitizedActual = sanitize(isolatedActual);
+ log.trace("Comparing sanitized values: '{}' : '{}'", sanitizedExpected, sanitizedActual);
+ Assert.assertEquals(sanitizedExpected, sanitizedActual);
+
+ // Ensure we can actually parse the isolated value in a predicable fashion
+ Version versionExpected = Version.parse(finalExpected);
+ Version versionActual = parseStrict(sanitizedActual);
+ log.trace("Comparing parsed values: '{}' : '{}'", versionExpected, versionActual);
+ Assert.assertEquals(versionExpected, versionActual);
+ }
+
+ /**
+ * Make sure the JVM and our internal classes pass the most basic of "smoke" tests by comparing the major version
+ */
+ @Test
+ public void smokeTests() {
+ // Ensure we're stripping off "-LTS" even for internal versioning
+ Runtime.Version rv = Runtime.Version.parse("11.0.4+10-LTS");
+ Version semverActual = JavaVersion.toSemantic(rv);
+ Version semverExpected = Version.parse("11.0.4+10");
+ log.trace("Comparing toSemantic values '{}' : '{}'", semverExpected, semverActual);
+ Assert.assertEquals(semverActual, semverExpected);
+ log.trace("Ensuring toSemantic stripped '-LTS' suffix '{}' : '{}'", "10", semverExpected.buildMetadata().get());
+ Assert.assertFalse(semverExpected.buildMetadata().get().contains("-LTS"));
+
+ // JLink internal class version
+ Assert.assertTrue(parse(JLink.JAVA_DEFAULT_VERSION).majorVersion() >= 11);
+
+ // Currently installed Java version
+ Assert.assertTrue(current().majorVersion() >= 11);
+
+ // From ant properties
+ Properties antProperties = new Properties();
+ try {
+ antProperties.load(new FileReader(Paths.get("ant/project.properties").toAbsolutePath().toFile()));
+ } catch(IOException e) {
+ System.err.printf("Can't load properties file: %s", e.getLocalizedMessage());
+ }
+ String javaVersion = antProperties.getProperty("jlink.java.version");
+ // Ensures version in project.properties doesn't get corrupted by our own complicated parsing logic
+ Assert.assertEquals(Version.parse(javaVersion), parse(javaVersion));
+ }
+
+ @DataProvider(name = "comparisons")
+ public Object[][] comparisons() {
+ return new Object[][] {
+ { "1.8.0_202-b13", "<", "1.8.0+203" },
+ { "25+1", ">", "25.0.0" },
+ { "11.0.3+11", ">", "11.0.3+8" },
+ { "25-ea", "<", "25" },
+ { "25", "=", "25.0.0" },
+ { "27-ea+18", ">", "27-ea+8-1643" },
+ { "17.0.9+10-jvmci-23.0-b22", ">", "17.0.9+9" }
+ };
+ }
+
+ @Test(dataProvider = "comparisons")
+ public void compareTests(String lhs, String operator, String rhs) {
+ Version left = JavaVersion.parse(lhs);
+ Version right = JavaVersion.parse(rhs);
+ int diff = left.compareTo(right);
+
+ log.trace("Raw: '{}' {} '{}'", lhs, operator, rhs);
+ log.trace("Parsed: '{}' {} '{}'", left, operator, right);
+
+ switch(operator) {
+ case "<":
+ Assert.assertEquals(diff, -1);
+ break;
+ case ">":
+ Assert.assertEquals(diff, 1);
+ break;
+ case "=":
+ Assert.assertEquals(diff, 0);
+ break;
+ }
+ }
+}