diff --git a/compat/maven-compat/src/main/java/org/apache/maven/toolchain/ToolchainManagerFactory.java b/compat/maven-compat/src/main/java/org/apache/maven/toolchain/ToolchainManagerFactory.java index 5123ff11d081..153cfd0384e0 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/toolchain/ToolchainManagerFactory.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/toolchain/ToolchainManagerFactory.java @@ -155,7 +155,10 @@ public Optional createDefaultToolchain() }); } allFactories.putAll(v4Factories); - return new org.apache.maven.impl.DefaultToolchainManager(allFactories, logger) {}; + org.apache.maven.impl.JdkToolchainDiscoverer discoverer = lookup.lookupOptional( + org.apache.maven.impl.JdkToolchainDiscoverer.class) + .orElse(null); + return new org.apache.maven.impl.DefaultToolchainManager(allFactories, discoverer, logger) {}; } public class DefaultToolchainManagerV4 implements org.apache.maven.api.services.ToolchainManager { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java index cc2cb0360bc3..0e9e24b4b4a5 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java @@ -26,6 +26,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; +import org.apache.maven.api.JavaToolchain; import org.apache.maven.api.Project; import org.apache.maven.api.Session; import org.apache.maven.api.SessionData; @@ -35,12 +36,16 @@ import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; +import org.apache.maven.api.model.Build; +import org.apache.maven.api.model.Plugin; +import org.apache.maven.api.model.Source; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.ToolchainFactory; import org.apache.maven.api.services.ToolchainFactoryException; import org.apache.maven.api.services.ToolchainManager; import org.apache.maven.api.services.ToolchainManagerException; import org.apache.maven.api.toolchain.ToolchainModel; +import org.apache.maven.api.xml.XmlNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,18 +53,38 @@ @Singleton public class DefaultToolchainManager implements ToolchainManager { private final Map factories; + private final JdkToolchainDiscoverer discoverer; private final Logger logger; @Inject + public DefaultToolchainManager(Map factories, JdkToolchainDiscoverer discoverer) { + this(factories, discoverer, null); + } + + /** + * Convenience constructor without a discoverer — auto-selection will skip + * filesystem discovery. Used by tests and IT harnesses. + */ public DefaultToolchainManager(Map factories) { - this(factories, null); + this(factories, null, null); + } + + /** + * Convenience constructor without a discoverer, with custom logger. + * Used by the compatibility layer and tests. + */ + public DefaultToolchainManager(Map factories, Logger logger) { + this(factories, null, logger); } /** - * Used for tests only + * Full-control constructor with all parameters. + * Used by the compatibility layer ({@code ToolchainManagerFactory}) and tests. */ - protected DefaultToolchainManager(Map factories, Logger logger) { + public DefaultToolchainManager( + Map factories, JdkToolchainDiscoverer discoverer, Logger logger) { this.factories = factories; + this.discoverer = discoverer; this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultToolchainManager.class); } @@ -89,7 +114,21 @@ public Optional getToolchainFromBuildContext(@Nonnull Session session throws ToolchainManagerException { Map context = retrieveContext(session); ToolchainModel model = (ToolchainModel) context.get("toolchain-" + type); - return Optional.ofNullable(model).flatMap(this::createToolchain); + if (model != null) { + return createToolchain(model); + } + + // For JDK type, try auto-selection based on project's target version + if ("jdk".equals(type)) { + Optional autoSelected = autoSelectJdkToolchain(session); + if (autoSelected.isPresent()) { + // Cache the selection so subsequent calls for this project return the same toolchain + context.put("toolchain-" + type, autoSelected.get().getModel()); + } + return autoSelected; + } + + return Optional.empty(); } @Override @@ -98,6 +137,200 @@ public void storeToolchainToBuildContext(@Nonnull Session session, @Nonnull Tool context.put("toolchain-" + toolchain.getType(), toolchain.getModel()); } + /** + * Attempts to automatically select a JDK toolchain when the running JDK + * does not support the project's required {@code --source}/{@code --release} level. + *

+ * First searches configured toolchains (from {@code toolchains.xml}), then falls back + * to lazy filesystem discovery. Normal builds pay zero cost — discovery only runs + * when the running JDK is incompatible and no configured toolchain matches. + */ + Optional autoSelectJdkToolchain(Session session) { + int requiredSourceLevel = getProjectRequiredSourceLevel(session); + logger.debug("Auto-select JDK toolchain: requiredSourceLevel={}", requiredSourceLevel); + if (requiredSourceLevel <= 0) { + return Optional.empty(); + } + + int runningJdkMajor = getRunningJdkMajor(); + logger.debug( + "Auto-select JDK toolchain: runningJdkMajor={}, supportsLevel={}", + runningJdkMajor, + JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, requiredSourceLevel)); + if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, requiredSourceLevel)) { + return Optional.empty(); + } + + // 1. Search configured toolchains (from toolchains.xml) + List configuredToolchains = getToolchains(session, "jdk", null); + Toolchain bestMatch = findNewestCompatible(configuredToolchains, requiredSourceLevel); + + // 2. Fall back to lazy filesystem discovery + if (bestMatch == null && discoverer != null) { + logger.debug("No compatible JDK in configured toolchains, discovering JDKs from filesystem..."); + List discoveredModels = discoverer.discoverToolchains(session.getSystemProperties()); + List discoveredToolchains = discoveredModels.stream() + .map(this::createToolchain) + .flatMap(Optional::stream) + .toList(); + bestMatch = findNewestCompatible(discoveredToolchains, requiredSourceLevel); + } + + if (bestMatch != null) { + JavaToolchain jtc = (JavaToolchain) bestMatch; + logger.warn( + "Project requires --source {} which is not supported by JDK {}.", + requiredSourceLevel, + runningJdkMajor); + logger.warn( + "Automatically selected JDK {} (discovered at {}) for compilation.", + jtc.getJavaVersion(), + jtc.getJavaHome()); + logger.warn("To suppress this warning, configure the maven-toolchains-plugin explicitly"); + logger.warn("or set to a value supported by your JDK."); + return Optional.of(bestMatch); + } + + return Optional.empty(); + } + + /** + * Finds the newest JDK toolchain that supports the given source level. + */ + private Toolchain findNewestCompatible(List toolchains, int requiredSourceLevel) { + Toolchain bestMatch = null; + int bestVersion = 0; + for (Toolchain tc : toolchains) { + if (tc instanceof JavaToolchain jtc && jtc.getJavaVersion() != null) { + int tcMajor = JdkSourceLevelSupport.normalizeSourceLevel( + jtc.getJavaVersion().toString()); + if (tcMajor > 0 && JdkSourceLevelSupport.supportsSourceLevel(tcMajor, requiredSourceLevel)) { + if (tcMajor > bestVersion) { + bestVersion = tcMajor; + bestMatch = tc; + } + } + } + } + return bestMatch; + } + + /** + * Reads the project's required source level from either Model 4.1.0 + * {@code } elements or legacy properties + * ({@code maven.compiler.release}, {@code maven.compiler.source}). + * + * @return the required source level as a major version, or {@code -1} if none is specified + */ + int getProjectRequiredSourceLevel(Session session) { + Optional current = session.getService(Lookup.class).lookupOptional(Project.class); + if (current.isEmpty()) { + return -1; + } + + Project project = current.get(); + + // Check Model 4.1.0 elements + Build build = project.getModel().getBuild(); + if (build != null) { + List sources = build.getSources(); + if (sources != null) { + for (Source source : sources) { + String targetVersion = source.getTargetVersion(); + if (targetVersion != null && !targetVersion.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(targetVersion); + if (level > 0) { + return level; + } + } + } + } + } + + // Fall back to legacy properties + Map properties = project.getModel().getProperties(); + if (properties != null) { + // maven.compiler.release takes precedence + String release = properties.get("maven.compiler.release"); + if (release != null && !release.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(release); + if (level > 0) { + return level; + } + } + + // Then maven.compiler.source + String source = properties.get("maven.compiler.source"); + if (source != null && !source.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(source); + if (level > 0) { + return level; + } + } + } + + // Fall back to compiler plugin configuration (, ) + int pluginLevel = getSourceLevelFromCompilerPlugin(build); + if (pluginLevel > 0) { + return pluginLevel; + } + + return -1; + } + + /** + * Reads the source level from the maven-compiler-plugin configuration. + * Checks both {@code } and {@code } elements in the plugin's + * {@code } block. + * + * @return the source level, or {@code -1} if not configured + */ + private int getSourceLevelFromCompilerPlugin(Build build) { + if (build == null) { + return -1; + } + for (Plugin plugin : build.getPlugins()) { + if ("maven-compiler-plugin".equals(plugin.getArtifactId()) + && (plugin.getGroupId() == null + || plugin.getGroupId().isEmpty() + || "org.apache.maven.plugins".equals(plugin.getGroupId()))) { + XmlNode config = plugin.getConfiguration(); + if (config != null) { + // takes precedence over + XmlNode releaseNode = config.child("release"); + if (releaseNode != null + && releaseNode.value() != null + && !releaseNode.value().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + releaseNode.value().trim()); + if (level > 0) { + return level; + } + } + XmlNode sourceNode = config.child("source"); + if (sourceNode != null + && sourceNode.value() != null + && !sourceNode.value().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + sourceNode.value().trim()); + if (level > 0) { + return level; + } + } + } + } + } + return -1; + } + + /** + * Returns the major version of the running JDK. + * Extracted as a method so tests can override it. + */ + int getRunningJdkMajor() { + return JdkSourceLevelSupport.getRunningJdkMajor(); + } + private Optional createToolchain(ToolchainModel model) { String type = Objects.requireNonNull(model.getType(), "model.getType()"); ToolchainFactory factory = factories.get(type); diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java new file mode 100644 index 000000000000..3ae3878d0dad --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java @@ -0,0 +1,134 @@ +/* + * 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.apache.maven.impl; + +/** + * Utility class for JDK source level compatibility checks. + *

+ * Maps JDK major versions to their supported {@code --source}/{@code --release} levels, + * based on the javac retirement schedule defined in + * JEP 182 and subsequent JDK releases. + *

+ * The retirement schedule follows these milestones: + *

    + *
  • JDK 9: removed {@code --source 1} through {@code 5}, minimum is {@code 6}
  • + *
  • JDK 12: removed {@code --source 6}, minimum is {@code 7}
  • + *
  • JDK 21: removed {@code --source 7}, minimum is {@code 8}
  • + *
+ */ +final class JdkSourceLevelSupport { + + private JdkSourceLevelSupport() {} + + /** + * Returns the minimum {@code --source} level supported by a given JDK major version. + * + * @param jdkMajor the JDK major version (e.g., {@code 17}, {@code 21}) + * @return the minimum supported source level + */ + static int minimumSupportedSourceLevel(int jdkMajor) { + if (jdkMajor <= 8) { + return 1; + } + if (jdkMajor <= 11) { + return 6; + } + if (jdkMajor <= 20) { + return 7; + } + return 8; + } + + /** + * Returns whether a given JDK version supports the specified {@code --source} level. + * + * @param jdkMajor the JDK major version + * @param sourceLevel the desired source level + * @return {@code true} if the JDK supports the source level + */ + static boolean supportsSourceLevel(int jdkMajor, int sourceLevel) { + return sourceLevel >= minimumSupportedSourceLevel(jdkMajor) && sourceLevel <= jdkMajor; + } + + /** + * Normalizes a source level string to a major version number. + *

+ * Handles legacy formats: + *

    + *
  • {@code "1.5"} → {@code 5}
  • + *
  • {@code "1.8"} → {@code 8}
  • + *
  • {@code "11"} → {@code 11}
  • + *
  • {@code "21.0.1"} → {@code 21}
  • + *
+ * + * @param version the source level string + * @return the normalized major version, or {@code -1} if the string cannot be parsed + */ + static int normalizeSourceLevel(String version) { + if (version == null || version.isEmpty()) { + return -1; + } + version = version.trim(); + // Handle "1.x" legacy format (e.g., "1.5", "1.8", "1.8.0_392") + if (version.startsWith("1.") && version.length() > 2) { + String rest = version.substring(2); + // Strip any trailing qualifiers (e.g. "8.0_392" → "8") + int sep = indexOfNonDigit(rest); + if (sep > 0) { + rest = rest.substring(0, sep); + } + try { + return Integer.parseInt(rest); + } catch (NumberFormatException e) { + return -1; + } + } + // Handle dotted versions like "21.0.1" — take the first segment + int dotIndex = version.indexOf('.'); + if (dotIndex > 0) { + version = version.substring(0, dotIndex); + } + try { + return Integer.parseInt(version); + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * Returns the index of the first non-digit character in the string, or -1 if all characters are digits. + */ + private static int indexOfNonDigit(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return i; + } + } + return -1; + } + + /** + * Returns the major version of the currently running JDK. + * + * @return the running JDK major version + */ + static int getRunningJdkMajor() { + return Runtime.version().feature(); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkToolchainDiscoverer.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkToolchainDiscoverer.java new file mode 100644 index 000000000000..964441a1edad --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkToolchainDiscoverer.java @@ -0,0 +1,336 @@ +/* + * 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.apache.maven.impl; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.apache.maven.api.di.Named; +import org.apache.maven.api.di.Singleton; +import org.apache.maven.api.toolchain.ToolchainModel; +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.impl.util.Os; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Discovers JDK installations on the local filesystem by scanning well-known + * directories, environment variables, and tool manager locations. + *

+ * This is used by {@link DefaultToolchainManager} as a lazy fallback when auto-selection + * needs a compatible JDK but none are configured in {@code toolchains.xml}. + * Discovery only runs when the running JDK cannot compile the project's source level + * and no configured toolchain matches — normal builds pay zero cost. + *

+ * JDK version is read from the {@code release} file present in every JDK since Java 9 + * (and backported to JDK 8u updates), avoiding the need to execute {@code java} processes. + *

+ * All environment and system property access goes through the {@code properties} map + * supplied by the caller (typically {@link org.apache.maven.api.Session#getSystemProperties()}), + * where environment variables are available as {@code env.VAR_NAME} entries and JVM system + * properties as plain keys ({@code java.home}, {@code user.home}, etc.). + */ +@Named +@Singleton +public class JdkToolchainDiscoverer { + + private static final Logger LOGGER = LoggerFactory.getLogger(JdkToolchainDiscoverer.class); + + private volatile List cachedToolchains; + + /** + * Returns discovered JDK toolchain models. Results are cached after first invocation + * (the properties map from the first call wins; subsequent calls with different + * properties still return the cached result, since JDK locations don't change mid-build). + * + * @param properties system properties map (from {@code Session.getSystemProperties()}). + * Environment variables are expected as {@code env.VAR_NAME} entries. + */ + public List discoverToolchains(Map properties) { + List result = cachedToolchains; + if (result == null) { + synchronized (this) { + result = cachedToolchains; + if (result == null) { + result = doDiscover(properties); + cachedToolchains = result; + } + } + } + return result; + } + + private List doDiscover(Map properties) { + Set candidates = new LinkedHashSet<>(); + collectFromEnvironment(candidates, properties); + collectFromToolManagers(candidates, properties); + collectFromSystemDirectories(candidates, properties); + + List toolchains = new ArrayList<>(); + for (Path candidate : candidates) { + try { + Path jdkHome = resolveJdkHome(candidate); + if (jdkHome != null && isValidJdkHome(jdkHome)) { + Optional model = buildToolchainModel(jdkHome); + model.ifPresent(toolchains::add); + } + } catch (Exception e) { + LOGGER.debug("Skipping JDK candidate {}: {}", candidate, e.getMessage()); + } + } + + LOGGER.debug("Discovered {} JDK installation(s) on the filesystem", toolchains.size()); + return List.copyOf(toolchains); + } + + /** + * Collects JDK candidates from environment variables matching {@code JAVA*_HOME}. + * Environment variables are read from the properties map as {@code env.VAR_NAME} entries. + * Also scans the parent directory of {@code JAVA_HOME} for sibling JDK installations + * (common in CI environments and container images where multiple JDKs are installed + * under the same parent directory). + */ + void collectFromEnvironment(Set candidates, Map properties) { + // Current JDK (from java.home system property) + String javaHome = properties.get("java.home"); + if (javaHome != null) { + addCandidate(candidates, Paths.get(javaHome)); + } + + // env.JAVA*_HOME env vars (e.g. env.JAVA11_HOME, env.JAVA17_HOME, env.JAVA_HOME) + for (Map.Entry entry : properties.entrySet()) { + String name = entry.getKey(); + if (name.startsWith("env.JAVA") && name.endsWith("_HOME")) { + Path jdkPath = Paths.get(entry.getValue()); + addCandidate(candidates, jdkPath); + // Also scan sibling directories — in CI and container environments, + // multiple JDKs are often installed under the same parent directory + // (e.g. /toolchain/jdk-8, /toolchain/jdk-11, /toolchain/jdk-21) + Path parent = jdkPath.getParent(); + if (parent != null) { + scanSubdirectories(candidates, parent); + } + } + } + } + + /** + * Collects JDK candidates from common tool manager directories under the user's home. + */ + void collectFromToolManagers(Set candidates, Map properties) { + String userHomeProp = properties.get("user.home"); + if (userHomeProp == null) { + return; + } + Path userHome = Paths.get(userHomeProp); + + // IntelliJ IDEA / common + scanSubdirectories(candidates, userHome.resolve(".jdks")); + // Maven-managed JDKs + scanSubdirectories(candidates, userHome.resolve(".m2").resolve("jdks")); + // SDKMAN + scanSubdirectories( + candidates, userHome.resolve(".sdkman").resolve("candidates").resolve("java")); + // Gradle + scanSubdirectories(candidates, userHome.resolve(".gradle").resolve("jdks")); + // jEnv + scanSubdirectories(candidates, userHome.resolve(".jenv").resolve("versions")); + // JBang + scanSubdirectories( + candidates, userHome.resolve(".jbang").resolve("cache").resolve("jdks")); + // asdf + scanSubdirectories( + candidates, userHome.resolve(".asdf").resolve("installs").resolve("java")); + // Jabba + scanSubdirectories(candidates, userHome.resolve(".jabba").resolve("jdk")); + // mise (formerly rtx) + scanSubdirectories( + candidates, + userHome.resolve(".local") + .resolve("share") + .resolve("mise") + .resolve("installs") + .resolve("java")); + } + + /** + * Collects JDK candidates from OS-specific system directories. + */ + void collectFromSystemDirectories(Set candidates, Map properties) { + if (Os.IS_WINDOWS) { + collectWindowsDirectories(candidates, properties); + } else if (Os.isFamily("mac")) { + collectMacDirectories(candidates, properties); + } else { + collectLinuxDirectories(candidates); + } + } + + private void collectLinuxDirectories(Set candidates) { + scanSubdirectories(candidates, Paths.get("/usr/lib/jvm")); + scanSubdirectories(candidates, Paths.get("/usr/lib64/jvm")); + scanSubdirectories(candidates, Paths.get("/usr/jdk")); + scanSubdirectories(candidates, Paths.get("/usr/java")); + scanSubdirectories(candidates, Paths.get("/usr/local/java")); + scanSubdirectories(candidates, Paths.get("/opt/java")); + scanSubdirectories(candidates, Paths.get("/opt/hostedtoolcache")); + } + + private void collectMacDirectories(Set candidates, Map properties) { + scanSubdirectories(candidates, Paths.get("/Library/Java/JavaVirtualMachines")); + String userHomeProp = properties.get("user.home"); + if (userHomeProp != null) { + Path userHome = Paths.get(userHomeProp); + scanSubdirectories( + candidates, userHome.resolve("Library").resolve("Java").resolve("JavaVirtualMachines")); + } + } + + private void collectWindowsDirectories(Set candidates, Map properties) { + Path progFiles = Paths.get("C:\\Program Files"); + scanSubdirectories(candidates, progFiles.resolve("Java")); + scanSubdirectories(candidates, progFiles.resolve("Eclipse Adoptium")); + scanSubdirectories(candidates, progFiles.resolve("Zulu")); + scanSubdirectories(candidates, progFiles.resolve("Amazon Corretto")); + scanSubdirectories(candidates, progFiles.resolve("BellSoft")); + // Scoop + String userHomeProp = properties.get("user.home"); + if (userHomeProp != null) { + Path userHome = Paths.get(userHomeProp); + scanSubdirectories(candidates, userHome.resolve("scoop").resolve("apps")); + } + } + + /** + * Lists immediate subdirectories of the given directory and adds them as candidates. + */ + private void scanSubdirectories(Set candidates, Path directory) { + if (!Files.isDirectory(directory)) { + return; + } + try (DirectoryStream stream = Files.newDirectoryStream(directory, Files::isDirectory)) { + for (Path child : stream) { + addCandidate(candidates, child); + } + } catch (IOException e) { + LOGGER.debug("Cannot scan directory {}: {}", directory, e.getMessage()); + } + } + + private void addCandidate(Set candidates, Path path) { + try { + candidates.add(path.toRealPath()); + } catch (IOException e) { + // Broken symlink or inaccessible — add normalized path as fallback + candidates.add(path.normalize().toAbsolutePath()); + } + } + + /** + * Resolves the actual JDK home from a candidate path. + * On macOS, JDKs may be nested under {@code Contents/Home}. + */ + Path resolveJdkHome(Path candidate) { + if (isValidJdkHome(candidate)) { + return candidate; + } + // macOS bundle layout: /path/to/jdk-17.jdk/Contents/Home + Path contentsHome = candidate.resolve("Contents").resolve("Home"); + if (isValidJdkHome(contentsHome)) { + return contentsHome; + } + return null; + } + + /** + * Checks if a directory is a valid JDK home by looking for {@code bin/javac}. + */ + boolean isValidJdkHome(Path jdkHome) { + Path bin = jdkHome.resolve("bin"); + return Files.exists(bin.resolve("javac")) || Files.exists(bin.resolve("javac.exe")); + } + + /** + * Builds a {@link ToolchainModel} from a validated JDK home by reading the {@code release} file. + * + * @return the model, or empty if the version cannot be determined + */ + Optional buildToolchainModel(Path jdkHome) { + String version = readVersionFromRelease(jdkHome); + if (version == null) { + LOGGER.debug("Cannot determine version for JDK at {}, skipping", jdkHome); + return Optional.empty(); + } + + int majorVersion = JdkSourceLevelSupport.normalizeSourceLevel(version); + if (majorVersion <= 0) { + LOGGER.debug("Cannot parse major version from '{}' for JDK at {}, skipping", version, jdkHome); + return Optional.empty(); + } + + XmlNode jdkHomeNode = XmlNode.newInstance("jdkHome", jdkHome.toString()); + XmlNode configuration = XmlNode.newInstance("configuration", List.of(jdkHomeNode)); + + ToolchainModel model = ToolchainModel.newBuilder() + .type("jdk") + .provides(Map.of("version", String.valueOf(majorVersion))) + .configuration(configuration) + .build(); + + LOGGER.debug("Discovered JDK {} at {}", majorVersion, jdkHome); + return Optional.of(model); + } + + /** + * Reads the {@code JAVA_VERSION} property from the JDK's {@code release} file. + * The release file format uses shell-style assignments: {@code JAVA_VERSION="17.0.2"}. + * + * @return the version string (e.g. "17.0.2"), or null if not found + */ + String readVersionFromRelease(Path jdkHome) { + Path releaseFile = jdkHome.resolve("release"); + if (!Files.exists(releaseFile)) { + return null; + } + try { + for (String line : Files.readAllLines(releaseFile)) { + if (line.startsWith("JAVA_VERSION=")) { + String value = line.substring("JAVA_VERSION=".length()).trim(); + // Remove surrounding quotes + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return value.isEmpty() ? null : value; + } + } + } catch (IOException e) { + LOGGER.debug("Cannot read release file at {}: {}", releaseFile, e.getMessage()); + } + return null; + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java index cec13a201948..11cdf9be4d9b 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java @@ -23,10 +23,15 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import org.apache.maven.api.JavaToolchain; import org.apache.maven.api.Project; import org.apache.maven.api.Session; import org.apache.maven.api.SessionData; import org.apache.maven.api.Toolchain; +import org.apache.maven.api.Version; +import org.apache.maven.api.model.Build; +import org.apache.maven.api.model.Model; +import org.apache.maven.api.model.Source; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.ToolchainFactory; import org.apache.maven.api.toolchain.ToolchainModel; @@ -35,12 +40,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -123,4 +130,366 @@ void retrieveContextWithoutProject() { void getToolchainsWithNullType() { assertThrows(NullPointerException.class, () -> manager.getToolchains(session, null, null)); } + + // --- Auto-selection tests --- + + @Test + void autoSelectJdkToolchainWhenNoTargetVersion() { + // Project has no targetVersion configured — should not auto-select + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder().build(Build.newBuilder().build()).build(); + when(project.getModel()).thenReturn(model); + + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory)) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + Optional result = testManager.autoSelectJdkToolchain(session); + assertTrue(result.isEmpty()); + } + + @Test + void autoSelectJdkToolchainWhenRunningJdkSupportsLevel() { + // Project targets source 11, running JDK 17 supports it — no auto-select + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("11").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory)) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + Optional result = testManager.autoSelectJdkToolchain(session); + assertTrue(result.isEmpty()); + } + + @Test + void autoSelectJdkToolchainWhenRunningJdkDoesNotSupportLevel() { + // Project targets source 6, running JDK 17 doesn't support it + // JDK 11 toolchain available and supports source 6 + Logger testLogger = mock(Logger.class); + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + // Set up project with targetVersion 6 + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("6").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + // Set up available JDK 11 toolchain + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + when(jdk11Toolchain.getJavaHome()).thenReturn("/usr/lib/jvm/java-11"); + + ToolchainModel jdk11Model = ToolchainModel.newBuilder().type("jdk").build(); + when(session.getToolchains()).thenReturn(List.of(jdk11Model)); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.autoSelectJdkToolchain(session); + + assertTrue(result.isPresent()); + assertEquals(jdk11Toolchain, result.get()); + verify(testLogger).warn("Project requires --source {} which is not supported by JDK {}.", 6, 17); + verify(testLogger) + .warn( + "Automatically selected JDK {} (discovered at {}) for compilation.", + jdk11Version, + "/usr/lib/jvm/java-11"); + } + + @Test + void autoSelectJdkToolchainPrefersNewestCompatible() { + // Project targets source 6, running JDK 17 + // JDK 8 and JDK 11 both support source 6; should select JDK 11 (newest) + Logger testLogger = mock(Logger.class); + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("6").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + // JDK 8 toolchain + JavaToolchain jdk8Toolchain = mock(JavaToolchain.class); + Version jdk8Version = mock(Version.class); + when(jdk8Version.toString()).thenReturn("8"); + when(jdk8Toolchain.getJavaVersion()).thenReturn(jdk8Version); + + // JDK 11 toolchain + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + when(jdk11Toolchain.getJavaHome()).thenReturn("/usr/lib/jvm/java-11"); + + // Use distinct provides so ToolchainModel.equals() distinguishes them + ToolchainModel jdk8Model = ToolchainModel.newBuilder() + .type("jdk") + .provides(Map.of("version", "8")) + .build(); + ToolchainModel jdk11Model = ToolchainModel.newBuilder() + .type("jdk") + .provides(Map.of("version", "11")) + .build(); + when(session.getToolchains()).thenReturn(List.of(jdk8Model, jdk11Model)); + when(jdkFactory.createToolchain(jdk8Model)).thenReturn(jdk8Toolchain); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.autoSelectJdkToolchain(session); + + assertTrue(result.isPresent()); + assertEquals(jdk11Toolchain, result.get()); + } + + @Test + void autoSelectJdkToolchainNoCompatibleToolchainAvailable() { + // Project targets source 5, running JDK 17 + // Only JDK 11 toolchain available (min source 6, doesn't support 5) + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory)) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("5").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + // JDK 11 doesn't support source 5 + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + + ToolchainModel jdk11Model = ToolchainModel.newBuilder().type("jdk").build(); + when(session.getToolchains()).thenReturn(List.of(jdk11Model)); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.autoSelectJdkToolchain(session); + assertTrue(result.isEmpty()); + } + + @Test + void autoSelectJdkToolchainFromLegacyProperties() { + // Project uses maven.compiler.release=6 (legacy property), running JDK 17 + Logger testLogger = mock(Logger.class); + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .properties(Map.of("maven.compiler.release", "6")) + .build(); + when(project.getModel()).thenReturn(model); + + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + when(jdk11Toolchain.getJavaHome()).thenReturn("/usr/lib/jvm/java-11"); + + ToolchainModel jdk11Model = ToolchainModel.newBuilder().type("jdk").build(); + when(session.getToolchains()).thenReturn(List.of(jdk11Model)); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.autoSelectJdkToolchain(session); + + assertTrue(result.isPresent()); + assertEquals(jdk11Toolchain, result.get()); + } + + @Test + void autoSelectJdkToolchainFromLegacySourceProperty() { + // Project uses maven.compiler.source=1.6 (legacy property), running JDK 17 + Logger testLogger = mock(Logger.class); + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .properties(Map.of("maven.compiler.source", "1.6")) + .build(); + when(project.getModel()).thenReturn(model); + + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + when(jdk11Toolchain.getJavaHome()).thenReturn("/usr/lib/jvm/java-11"); + + ToolchainModel jdk11Model = ToolchainModel.newBuilder().type("jdk").build(); + when(session.getToolchains()).thenReturn(List.of(jdk11Model)); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.autoSelectJdkToolchain(session); + + assertTrue(result.isPresent()); + assertEquals(jdk11Toolchain, result.get()); + } + + @Test + void getToolchainFromBuildContextAutoSelectsFallback() { + // Verify getToolchainFromBuildContext calls auto-selection when no explicit toolchain + Logger testLogger = mock(Logger.class); + Map context = new ConcurrentHashMap<>(); + SessionData data = mock(SessionData.class); + + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + when(session.getData()).thenReturn(data); + when(data.computeIfAbsent(any(), any())).thenReturn(context); + + Model model = Model.newBuilder() + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("6").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + JavaToolchain jdk11Toolchain = mock(JavaToolchain.class); + Version jdk11Version = mock(Version.class); + when(jdk11Version.toString()).thenReturn("11"); + when(jdk11Toolchain.getJavaVersion()).thenReturn(jdk11Version); + when(jdk11Toolchain.getJavaHome()).thenReturn("/usr/lib/jvm/java-11"); + + ToolchainModel jdk11Model = ToolchainModel.newBuilder().type("jdk").build(); + when(jdk11Toolchain.getModel()).thenReturn(jdk11Model); + when(session.getToolchains()).thenReturn(List.of(jdk11Model)); + when(jdkFactory.createToolchain(jdk11Model)).thenReturn(jdk11Toolchain); + when(jdkFactory.createDefaultToolchain()).thenReturn(Optional.empty()); + + Optional result = testManager.getToolchainFromBuildContext(session, "jdk"); + + assertTrue(result.isPresent()); + assertEquals(jdk11Toolchain, result.get()); + } + + @Test + void getToolchainFromBuildContextReturnsExplicitOverAutoSelect() { + // When an explicit toolchain is stored via storeToolchainToBuildContext, + // it takes precedence over auto-selection + Map context = new ConcurrentHashMap<>(); + SessionData data = mock(SessionData.class); + toolchainModel = ToolchainModel.newBuilder().type("jdk").build(); + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + when(session.getData()).thenReturn(data); + when(data.computeIfAbsent(any(), any())).thenReturn(context); + when(mockToolchain.getType()).thenReturn("jdk"); + when(mockToolchain.getModel()).thenReturn(toolchainModel); + when(jdkFactory.createToolchain(any(ToolchainModel.class))).thenReturn(mockToolchain); + + // Store explicit toolchain using the proper API + manager.storeToolchainToBuildContext(session, mockToolchain); + + // Now retrieve — should get the explicit one, not auto-select + Optional result = manager.getToolchainFromBuildContext(session, "jdk"); + + assertTrue(result.isPresent()); + assertEquals(mockToolchain, result.get()); + } + + @Test + void getToolchainFromBuildContextNonJdkTypeDoesNotAutoSelect() { + // Auto-selection should only apply to "jdk" type + Map context = new ConcurrentHashMap<>(); + SessionData data = mock(SessionData.class); + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + when(session.getData()).thenReturn(data); + when(data.computeIfAbsent(any(), any())).thenReturn(context); + + // No "otherType" factory registered; getToolchainFromBuildContext should return empty + // without attempting auto-selection + Optional result = manager.getToolchainFromBuildContext(session, "otherType"); + assertTrue(result.isEmpty()); + } + + @Test + void getProjectRequiredSourceLevelTargetVersionTakesPrecedence() { + // targetVersion in sources should take precedence over properties + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory)); + + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project)); + Model model = Model.newBuilder() + .properties(Map.of("maven.compiler.release", "11")) + .build(Build.newBuilder() + .sources(List.of(Source.newBuilder().targetVersion("8").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + assertEquals(8, testManager.getProjectRequiredSourceLevel(session)); + } + + @Test + void getProjectRequiredSourceLevelNoProject() { + when(session.getService(Lookup.class)).thenReturn(lookup); + when(lookup.lookupOptional(Project.class)).thenReturn(Optional.empty()); + + assertEquals(-1, manager.getProjectRequiredSourceLevel(session)); + } } diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java new file mode 100644 index 000000000000..e3b215f8ade4 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java @@ -0,0 +1,141 @@ +/* + * 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.apache.maven.impl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JdkSourceLevelSupportTest { + + @Test + void minimumSupportedSourceLevelJdk8AndEarlier() { + assertEquals(1, JdkSourceLevelSupport.minimumSupportedSourceLevel(7)); + assertEquals(1, JdkSourceLevelSupport.minimumSupportedSourceLevel(8)); + } + + @Test + void minimumSupportedSourceLevelJdk9To11() { + assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(9)); + assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(10)); + assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(11)); + } + + @Test + void minimumSupportedSourceLevelJdk12To20() { + assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(12)); + assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(15)); + assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(17)); + assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(20)); + } + + @Test + void minimumSupportedSourceLevelJdk21AndLater() { + assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(21)); + assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(22)); + assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(25)); + } + + @Test + void supportsSourceLevelJdk8() { + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 1)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 5)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 6)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 8)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(8, 9)); + } + + @Test + void supportsSourceLevelJdk11() { + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(11, 5)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 6)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 8)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 11)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(11, 12)); + } + + @Test + void supportsSourceLevelJdk17() { + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 5)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 6)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 7)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 8)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 11)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 17)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 18)); + } + + @Test + void supportsSourceLevelJdk21() { + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 6)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 7)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 8)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 11)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 17)); + assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 21)); + assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 22)); + } + + @Test + void normalizeSourceLevelLegacyFormat() { + assertEquals(5, JdkSourceLevelSupport.normalizeSourceLevel("1.5")); + assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("1.6")); + assertEquals(7, JdkSourceLevelSupport.normalizeSourceLevel("1.7")); + assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("1.8")); + } + + @Test + void normalizeSourceLevelModernFormat() { + assertEquals(5, JdkSourceLevelSupport.normalizeSourceLevel("5")); + assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("6")); + assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("8")); + assertEquals(9, JdkSourceLevelSupport.normalizeSourceLevel("9")); + assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel("11")); + assertEquals(17, JdkSourceLevelSupport.normalizeSourceLevel("17")); + assertEquals(21, JdkSourceLevelSupport.normalizeSourceLevel("21")); + } + + @Test + void normalizeSourceLevelDottedVersion() { + assertEquals(21, JdkSourceLevelSupport.normalizeSourceLevel("21.0.1")); + assertEquals(17, JdkSourceLevelSupport.normalizeSourceLevel("17.0.2")); + assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel("11.0.3")); + } + + @Test + void normalizeSourceLevelInvalid() { + assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel(null)); + assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel("")); + assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel("abc")); + assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel("${java.version}")); + } + + @Test + void normalizeSourceLevelWithWhitespace() { + assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel(" 11 ")); + assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel(" 1.8 ")); + } + + @Test + void getRunningJdkMajorReturnsPositive() { + assertTrue(JdkSourceLevelSupport.getRunningJdkMajor() > 0); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java new file mode 100644 index 000000000000..76d0d6e576d3 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java @@ -0,0 +1,226 @@ +/* + * 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.apache.maven.impl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.apache.maven.api.toolchain.ToolchainModel; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JdkToolchainDiscovererTest { + + @TempDir + Path tempDir; + + private final JdkToolchainDiscoverer discoverer = new JdkToolchainDiscoverer(); + + @Test + void isValidJdkHomeWithJavac() throws IOException { + Path jdkHome = createFakeJdk(tempDir, "jdk-17"); + assertTrue(discoverer.isValidJdkHome(jdkHome)); + } + + @Test + void isValidJdkHomeWithoutJavac() throws IOException { + Path jdkHome = tempDir.resolve("jdk-no-javac"); + Files.createDirectories(jdkHome.resolve("bin")); + assertFalse(discoverer.isValidJdkHome(jdkHome)); + } + + @Test + void isValidJdkHomeNonExistent() { + assertFalse(discoverer.isValidJdkHome(tempDir.resolve("nonexistent"))); + } + + @Test + void readVersionFromReleaseFile() throws IOException { + Path jdkHome = tempDir.resolve("jdk-17"); + Files.createDirectories(jdkHome); + Files.writeString(jdkHome.resolve("release"), "JAVA_VERSION=\"17.0.2\"\nIMPLEMENTOR=\"Eclipse Adoptium\"\n"); + + assertEquals("17.0.2", discoverer.readVersionFromRelease(jdkHome)); + } + + @Test + void readVersionFromReleaseFileJdk8Format() throws IOException { + Path jdkHome = tempDir.resolve("jdk-8"); + Files.createDirectories(jdkHome); + Files.writeString(jdkHome.resolve("release"), "JAVA_VERSION=\"1.8.0_392\"\n"); + + assertEquals("1.8.0_392", discoverer.readVersionFromRelease(jdkHome)); + } + + @Test + void readVersionFromReleaseFileNoQuotes() throws IOException { + Path jdkHome = tempDir.resolve("jdk-11"); + Files.createDirectories(jdkHome); + Files.writeString(jdkHome.resolve("release"), "JAVA_VERSION=11.0.21\n"); + + assertEquals("11.0.21", discoverer.readVersionFromRelease(jdkHome)); + } + + @Test + void readVersionFromMissingReleaseFile() { + assertNull(discoverer.readVersionFromRelease(tempDir.resolve("no-release"))); + } + + @Test + void readVersionFromReleaseFileWithoutJavaVersion() throws IOException { + Path jdkHome = tempDir.resolve("jdk-bad"); + Files.createDirectories(jdkHome); + Files.writeString(jdkHome.resolve("release"), "IMPLEMENTOR=\"Some Vendor\"\n"); + + assertNull(discoverer.readVersionFromRelease(jdkHome)); + } + + @Test + void buildToolchainModelFromValidJdk() throws IOException { + Path jdkHome = createFakeJdkWithRelease(tempDir, "jdk-17", "17.0.2"); + + Optional result = discoverer.buildToolchainModel(jdkHome); + + assertTrue(result.isPresent()); + ToolchainModel model = result.get(); + assertEquals("jdk", model.getType()); + assertEquals("17", model.getProvides().get("version")); + assertNotNull(model.getConfiguration()); + assertEquals( + jdkHome.toString(), model.getConfiguration().child("jdkHome").value()); + } + + @Test + void buildToolchainModelFromJdk8() throws IOException { + Path jdkHome = createFakeJdkWithRelease(tempDir, "jdk-8", "1.8.0_392"); + + Optional result = discoverer.buildToolchainModel(jdkHome); + + assertTrue(result.isPresent()); + assertEquals("8", result.get().getProvides().get("version")); + } + + @Test + void buildToolchainModelWithoutReleaseFile() throws IOException { + Path jdkHome = createFakeJdk(tempDir, "jdk-old"); + + Optional result = discoverer.buildToolchainModel(jdkHome); + + assertFalse(result.isPresent()); + } + + @Test + void resolveJdkHomeDirectPath() throws IOException { + Path jdkHome = createFakeJdk(tempDir, "jdk-17"); + + assertEquals(jdkHome, discoverer.resolveJdkHome(jdkHome)); + } + + @Test + void resolveJdkHomeMacOsBundle() throws IOException { + Path bundleRoot = tempDir.resolve("jdk-17.jdk"); + Path contentsHome = bundleRoot.resolve("Contents").resolve("Home"); + Files.createDirectories(contentsHome.resolve("bin")); + Files.createFile(contentsHome.resolve("bin").resolve("javac")); + + assertEquals(contentsHome, discoverer.resolveJdkHome(bundleRoot)); + } + + @Test + void resolveJdkHomeInvalidPath() { + assertNull(discoverer.resolveJdkHome(tempDir.resolve("nonexistent"))); + } + + @Test + void discoverToolchainsIsCached() { + Map properties = Map.of("user.home", tempDir.toString()); + // Two calls should return the same list instance (cached) + var first = discoverer.discoverToolchains(properties); + var second = discoverer.discoverToolchains(properties); + assertNotNull(first); + assertTrue(first == second, "Expected cached result (same instance)"); + } + + @Test + void collectFromEnvironmentUsesProperties() throws IOException { + Path jdkHome = createFakeJdk(tempDir, "jdk-21"); + Map properties = Map.of( + "java.home", jdkHome.toString(), + "env.JAVA17_HOME", tempDir.resolve("jdk-17-env").toString()); + + Set candidates = new LinkedHashSet<>(); + discoverer.collectFromEnvironment(candidates, properties); + + assertTrue(candidates.stream().anyMatch(p -> p.toString().contains("jdk-21")), "Should include java.home"); + assertTrue( + candidates.stream().anyMatch(p -> p.toString().contains("jdk-17-env")), + "Should include env.JAVA17_HOME"); + } + + @Test + void collectFromToolManagersUsesProperties() throws IOException { + Path fakeHome = tempDir.resolve("fakehome"); + Path jdksDir = fakeHome.resolve(".jdks"); + Path jdk21 = jdksDir.resolve("temurin-21"); + Files.createDirectories(jdk21); + + Map properties = Map.of("user.home", fakeHome.toString()); + + Set candidates = new LinkedHashSet<>(); + discoverer.collectFromToolManagers(candidates, properties); + + assertTrue( + candidates.stream().anyMatch(p -> p.toString().contains("temurin-21")), "Should find JDK in ~/.jdks"); + } + + @Test + void collectFromToolManagersSkipsWhenNoUserHome() { + Map properties = Map.of(); + + Set candidates = new LinkedHashSet<>(); + discoverer.collectFromToolManagers(candidates, properties); + + assertTrue(candidates.isEmpty(), "Should collect nothing without user.home"); + } + + private Path createFakeJdk(Path parent, String name) throws IOException { + Path jdkHome = parent.resolve(name); + Path binDir = jdkHome.resolve("bin"); + Files.createDirectories(binDir); + Files.createFile(binDir.resolve("javac")); + return jdkHome; + } + + private Path createFakeJdkWithRelease(Path parent, String name, String version) throws IOException { + Path jdkHome = createFakeJdk(parent, name); + Files.writeString(jdkHome.resolve("release"), "JAVA_VERSION=\"" + version + "\"\n"); + return jdkHome; + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITAutoJdkToolchainSelectTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITAutoJdkToolchainSelectTest.java new file mode 100644 index 000000000000..894d832ff4d0 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITAutoJdkToolchainSelectTest.java @@ -0,0 +1,141 @@ +/* + * 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.apache.maven.it; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Integration tests for automatic JDK toolchain selection. + *

+ * When the running JDK does not support the project's required {@code --source}/{@code --release} + * level, Maven should automatically search configured toolchains and select a compatible JDK. + */ +class MavenITAutoJdkToolchainSelectTest extends AbstractMavenIntegrationTestCase { + + /** + * Verifies that Maven auto-selects a JDK toolchain when the running JDK + * does not support the project's required source level. + *

+ * The project declares {@code maven.compiler.source=6}, which is not supported + * by JDK 12+ (minimum source level 7 for JDK 12-20, 8 for JDK 21+). + * A JDK 11 toolchain is configured in toolchains.xml and should be auto-selected. + */ + @Test + void testAutoSelectToolchainWhenSourceLevelUnsupported() throws Exception { + Path testDir = extractResources("auto-jdk-toolchain-select"); + + // Create a fake JDK home with bin/javac for the toolchain + Path javaHome = testDir.resolve("fakeJdk11"); + Path binDir = javaHome.resolve("bin"); + Files.createDirectories(binDir); + if (!Files.exists(binDir.resolve("javac"))) { + ItUtils.createFile(binDir.resolve("javac")); + } + if (!Files.exists(binDir.resolve("javac.exe"))) { + ItUtils.createFile(binDir.resolve("javac.exe")); + } + + Verifier verifier = newVerifier(testDir); + // Clear the default compiler properties set by newVerifier() — the POM defines + // maven.compiler.source=6 and we need the effective model to reflect that, not + // the system property override of 8 from MAVEN_OPTS. + verifier.getSystemProperties().remove("maven.compiler.source"); + verifier.getSystemProperties().remove("maven.compiler.target"); + verifier.getSystemProperties().remove("maven.compiler.release"); + + Map filterProps = verifier.newDefaultFilterMap(); + filterProps.put("@javaHome@", javaHome.toString()); + verifier.filterFile("toolchains.xml", "toolchains.xml", filterProps); + + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("--toolchains"); + verifier.addCliArgument("toolchains.xml"); + verifier.addCliArgument("initialize"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify the auto-selection warning was logged + verifier.verifyTextInLog("Automatically selected JDK"); + + // Verify the toolchain was auto-selected and find-tool found javac + verifier.verifyFilePresent("target/tool.properties"); + Properties toolProps = verifier.loadProperties("target/tool.properties"); + assertEquals("jdk", toolProps.getProperty("toolchain.type"), "Auto-selected toolchain type should be 'jdk'"); + } + + /** + * Verifies that Maven does NOT auto-select a JDK toolchain when the running + * JDK already supports the project's required source level. + *

+ * The project declares {@code maven.compiler.source=11}, which is supported + * by JDK 12+ (all current CI JDKs). No auto-selection should occur. + */ + @Test + void testNoAutoSelectWhenSourceLevelSupported() throws Exception { + Path testDir = extractResources("auto-jdk-toolchain-no-select"); + + // Create a fake JDK home (needed to create a valid toolchain) + Path javaHome = testDir.resolve("fakeJdk8"); + Path binDir = javaHome.resolve("bin"); + Files.createDirectories(binDir); + if (!Files.exists(binDir.resolve("javac"))) { + ItUtils.createFile(binDir.resolve("javac")); + } + if (!Files.exists(binDir.resolve("javac.exe"))) { + ItUtils.createFile(binDir.resolve("javac.exe")); + } + + Verifier verifier = newVerifier(testDir); + // Clear the default compiler properties to let the POM properties be used + verifier.getSystemProperties().remove("maven.compiler.source"); + verifier.getSystemProperties().remove("maven.compiler.target"); + verifier.getSystemProperties().remove("maven.compiler.release"); + + Map filterProps = verifier.newDefaultFilterMap(); + filterProps.put("@javaHome@", javaHome.toString()); + verifier.filterFile("toolchains.xml", "toolchains.xml", filterProps); + + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("--toolchains"); + verifier.addCliArgument("toolchains.xml"); + verifier.addCliArgument("initialize"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify no auto-selection warning was logged + verifier.verifyTextNotInLog("Automatically selected JDK"); + + // Verify no toolchain was auto-selected (find-tool returns nothing) + verifier.verifyFilePresent("target/tool.properties"); + Properties toolProps = verifier.loadProperties("target/tool.properties"); + assertNull( + toolProps.getProperty("toolchain.type"), + "No toolchain should be auto-selected when running JDK supports the source level"); + } +} diff --git a/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/pom.xml b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/pom.xml new file mode 100644 index 000000000000..872a38e92ff9 --- /dev/null +++ b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + org.apache.maven.its.auto-toolchain + test-no-select + 1.0-SNAPSHOT + + Maven Integration Test :: Auto JDK Toolchain No Selection + + Test that Maven does NOT auto-select a JDK toolchain when the running JDK + already supports the project's required source level. + + + + + 11 + + + + + + org.apache.maven.its.plugins + maven-it-plugin-toolchain + 2.1-SNAPSHOT + + + find-tool + + find-tool + + initialize + + target/tool.properties + jdk + javac + + + + + + + diff --git a/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/toolchains.xml b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/toolchains.xml new file mode 100644 index 000000000000..268626012778 --- /dev/null +++ b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/toolchains.xml @@ -0,0 +1,13 @@ + + + + + jdk + + 8 + + + @javaHome@ + + + diff --git a/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/pom.xml b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/pom.xml new file mode 100644 index 000000000000..c945c9f77698 --- /dev/null +++ b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + org.apache.maven.its.auto-toolchain + test-auto-select + 1.0-SNAPSHOT + + Maven Integration Test :: Auto JDK Toolchain Selection + + Test that Maven auto-selects a JDK toolchain when the running JDK + does not support the project's required source level. + + + + + 6 + + + + + + org.apache.maven.its.plugins + maven-it-plugin-toolchain + 2.1-SNAPSHOT + + + find-tool + + find-tool + + initialize + + target/tool.properties + jdk + javac + + + + + + + diff --git a/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/toolchains.xml b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/toolchains.xml new file mode 100644 index 000000000000..4bc2f45593c8 --- /dev/null +++ b/its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/toolchains.xml @@ -0,0 +1,13 @@ + + + + + jdk + + 11 + + + @javaHome@ + + +