From 42cbce4ca07d61182a67615c26d62d999c97501d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 30 Jul 2026 16:31:51 +0200 Subject: [PATCH 1/8] Auto-select JDK toolchain when running JDK cannot compile the project's source level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the running JDK does not support the project's --source/--release level (e.g., JDK 17 cannot compile --source 6), Maven now automatically searches configured toolchains for a compatible JDK and selects it for compilation. The auto-selection hooks into getToolchainFromBuildContext() — when no explicit toolchain has been set via maven-toolchains-plugin, the manager checks the project's targetVersion (Model 4.1.0) or legacy properties (maven.compiler.release, maven.compiler.source) against the running JDK's supported source levels. If the running JDK cannot handle the required level, it picks the newest compatible JDK from configured toolchains, caches the selection, and emits a warning. Key changes: - New JdkSourceLevelSupport utility mapping JDK versions to supported --source levels based on the javac retirement schedule (JEP 182) - DefaultToolchainManager.getToolchainFromBuildContext() now falls back to auto-selection for "jdk" type when no explicit toolchain is stored - Reads source level from Model 4.1.0 elements and legacy maven.compiler.release/source properties - Prefers the newest compatible JDK from configured toolchains Co-Authored-By: Claude Opus 4.6 --- .../maven/impl/DefaultToolchainManager.java | 138 ++++++- .../maven/impl/JdkSourceLevelSupport.java | 116 ++++++ .../impl/DefaultToolchainManagerTest.java | 369 ++++++++++++++++++ .../maven/impl/JdkSourceLevelSupportTest.java | 141 +++++++ 4 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java 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..ec87370e3286 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,6 +36,8 @@ 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.Source; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.ToolchainFactory; import org.apache.maven.api.services.ToolchainFactoryException; @@ -89,7 +92,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 +115,125 @@ 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. + *

+ * Searches configured toolchains for the newest JDK that supports the required + * source level. If found, emits a warning and returns it. + */ + Optional autoSelectJdkToolchain(Session session) { + int requiredSourceLevel = getProjectRequiredSourceLevel(session); + if (requiredSourceLevel <= 0) { + return Optional.empty(); + } + + int runningJdkMajor = getRunningJdkMajor(); + if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, requiredSourceLevel)) { + return Optional.empty(); + } + + // Search available toolchains for a compatible JDK, preferring the newest + List allToolchains = getToolchains(session, "jdk", null); + Toolchain bestMatch = null; + int bestVersion = 0; + + for (Toolchain tc : allToolchains) { + 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; + } + } + } + } + + 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(); + } + + /** + * 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; + } + } + } + + 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..30a1fecac4c1 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java @@ -0,0 +1,116 @@ +/* + * 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") + if (version.startsWith("1.") && version.length() > 2) { + try { + return Integer.parseInt(version.substring(2)); + } 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 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/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); + } +} From fb8ebb64fd0f8aadb30dafc9f975303311acac01 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 30 Jul 2026 17:43:18 +0200 Subject: [PATCH 2/8] Add integration tests for automatic JDK toolchain selection Two ITs verify the auto-selection behavior end-to-end through Maven's compat bridge: - testAutoSelectToolchainWhenSourceLevelUnsupported: project requires source 6 (unsupported by JDK 12+), verifies Maven auto-selects a compatible JDK 11 toolchain and logs a warning - testNoAutoSelectWhenSourceLevelSupported: project requires source 11 (supported by JDK 12+), verifies no auto-selection occurs Also adds debug logging to the auto-selection path for diagnostics. Co-Authored-By: Claude Opus 4.6 --- .../maven/impl/DefaultToolchainManager.java | 5 + .../it/MavenITAutoJdkToolchainSelectTest.java | 141 ++++++++++++++++++ .../auto-jdk-toolchain-no-select/pom.xml | 61 ++++++++ .../toolchains.xml | 13 ++ .../auto-jdk-toolchain-select/pom.xml | 61 ++++++++ .../auto-jdk-toolchain-select/toolchains.xml | 13 ++ 6 files changed, 294 insertions(+) create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITAutoJdkToolchainSelectTest.java create mode 100644 its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/pom.xml create mode 100644 its/core-it-suite/src/test/resources/auto-jdk-toolchain-no-select/toolchains.xml create mode 100644 its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/pom.xml create mode 100644 its/core-it-suite/src/test/resources/auto-jdk-toolchain-select/toolchains.xml 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 ec87370e3286..88430004eb8e 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 @@ -124,11 +124,16 @@ public void storeToolchainToBuildContext(@Nonnull Session session, @Nonnull Tool */ 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(); } 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..10d0776ce30f --- /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. + */ +public 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 + public 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 + public 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@ + + + From 8ec56c4ab4e4bfc5db6182258a0c6ffe4295a252 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 07:44:29 +0200 Subject: [PATCH 3/8] Remove public modifiers from IT class and methods Co-Authored-By: Claude Opus 4.6 --- .../apache/maven/it/MavenITAutoJdkToolchainSelectTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 10d0776ce30f..894d832ff4d0 100644 --- 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 @@ -34,7 +34,7 @@ * 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. */ -public class MavenITAutoJdkToolchainSelectTest extends AbstractMavenIntegrationTestCase { +class MavenITAutoJdkToolchainSelectTest extends AbstractMavenIntegrationTestCase { /** * Verifies that Maven auto-selects a JDK toolchain when the running JDK @@ -45,7 +45,7 @@ public class MavenITAutoJdkToolchainSelectTest extends AbstractMavenIntegrationT * A JDK 11 toolchain is configured in toolchains.xml and should be auto-selected. */ @Test - public void testAutoSelectToolchainWhenSourceLevelUnsupported() throws Exception { + void testAutoSelectToolchainWhenSourceLevelUnsupported() throws Exception { Path testDir = extractResources("auto-jdk-toolchain-select"); // Create a fake JDK home with bin/javac for the toolchain @@ -96,7 +96,7 @@ public void testAutoSelectToolchainWhenSourceLevelUnsupported() throws Exception * by JDK 12+ (all current CI JDKs). No auto-selection should occur. */ @Test - public void testNoAutoSelectWhenSourceLevelSupported() throws Exception { + void testNoAutoSelectWhenSourceLevelSupported() throws Exception { Path testDir = extractResources("auto-jdk-toolchain-no-select"); // Create a fake JDK home (needed to create a valid toolchain) From 4d88482097f5b37136c77a906f282876370f1534 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 10:46:19 +0200 Subject: [PATCH 4/8] Add lazy JDK discovery from filesystem for auto-selection fallback When no compatible JDK is found in toolchains.xml, Maven now lazily discovers JDKs from the filesystem by scanning well-known locations: JAVA*_HOME env vars, SDKMan, IntelliJ .jdks/, Gradle, jEnv, JBang, asdf, mise, and OS-specific paths (/usr/lib/jvm, etc.). Discovery only runs on the failure path (running JDK incompatible AND no configured toolchain matches), so normal builds pay zero cost. JDK version is read from the release file (no java process spawned). Co-Authored-By: Claude Opus 4.6 --- .../maven/impl/DefaultToolchainManager.java | 76 +++-- .../maven/impl/JdkSourceLevelSupport.java | 22 +- .../maven/impl/JdkToolchainDiscoverer.java | 310 ++++++++++++++++++ .../impl/JdkToolchainDiscovererTest.java | 180 ++++++++++ 4 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/impl/JdkToolchainDiscoverer.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java 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 88430004eb8e..45df52a465e0 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 @@ -51,18 +51,35 @@ @Singleton public class DefaultToolchainManager implements ToolchainManager { private final Map factories; + private final JdkToolchainDiscoverer discoverer; private final Logger logger; @Inject - public DefaultToolchainManager(Map factories) { - this(factories, null); + public DefaultToolchainManager(Map factories, JdkToolchainDiscoverer discoverer) { + this(factories, discoverer, null); } /** - * Used for tests only + * Used for tests only (no discoverer) + */ + protected DefaultToolchainManager(Map factories) { + this(factories, null, null); + } + + /** + * Used for tests only (no discoverer, custom logger) */ protected DefaultToolchainManager(Map factories, Logger logger) { + this(factories, null, logger); + } + + /** + * Used for tests only (full control) + */ + protected DefaultToolchainManager( + Map factories, JdkToolchainDiscoverer discoverer, Logger logger) { this.factories = factories; + this.discoverer = discoverer; this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultToolchainManager.class); } @@ -119,8 +136,9 @@ public void storeToolchainToBuildContext(@Nonnull Session session, @Nonnull Tool * Attempts to automatically select a JDK toolchain when the running JDK * does not support the project's required {@code --source}/{@code --release} level. *

- * Searches configured toolchains for the newest JDK that supports the required - * source level. If found, emits a warning and returns it. + * 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); @@ -138,22 +156,19 @@ Optional autoSelectJdkToolchain(Session session) { return Optional.empty(); } - // Search available toolchains for a compatible JDK, preferring the newest - List allToolchains = getToolchains(session, "jdk", null); - Toolchain bestMatch = null; - int bestVersion = 0; + // 1. Search configured toolchains (from toolchains.xml) + List configuredToolchains = getToolchains(session, "jdk", null); + Toolchain bestMatch = findNewestCompatible(configuredToolchains, requiredSourceLevel); - for (Toolchain tc : allToolchains) { - 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; - } - } - } + // 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(); + List discoveredToolchains = discoveredModels.stream() + .map(this::createToolchain) + .flatMap(Optional::stream) + .toList(); + bestMatch = findNewestCompatible(discoveredToolchains, requiredSourceLevel); } if (bestMatch != null) { @@ -174,6 +189,27 @@ Optional autoSelectJdkToolchain(Session session) { 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 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 index 30a1fecac4c1..3ae3878d0dad 100644 --- 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 @@ -85,10 +85,16 @@ static int normalizeSourceLevel(String version) { return -1; } version = version.trim(); - // Handle "1.x" legacy format (e.g., "1.5", "1.8") + // 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(version.substring(2)); + return Integer.parseInt(rest); } catch (NumberFormatException e) { return -1; } @@ -105,6 +111,18 @@ static int normalizeSourceLevel(String version) { } } + /** + * 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. * 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..78524a7e6c44 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkToolchainDiscoverer.java @@ -0,0 +1,310 @@ +/* + * 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. + */ +@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. + */ + public List discoverToolchains() { + List result = cachedToolchains; + if (result == null) { + synchronized (this) { + result = cachedToolchains; + if (result == null) { + result = doDiscover(); + cachedToolchains = result; + } + } + } + return result; + } + + private List doDiscover() { + Set candidates = new LinkedHashSet<>(); + collectFromEnvironment(candidates); + collectFromToolManagers(candidates); + collectFromSystemDirectories(candidates); + + 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}. + */ + void collectFromEnvironment(Set candidates) { + // Current JDK + String javaHome = System.getProperty("java.home"); + if (javaHome != null) { + addCandidate(candidates, Paths.get(javaHome)); + } + + // JAVA*_HOME env vars (e.g. JAVA11_HOME, JAVA17_HOME) + for (Map.Entry entry : System.getenv().entrySet()) { + String name = entry.getKey(); + if (name.startsWith("JAVA") && name.endsWith("_HOME")) { + addCandidate(candidates, Paths.get(entry.getValue())); + } + } + + // JAVA_HOME + String envJavaHome = System.getenv("JAVA_HOME"); + if (envJavaHome != null) { + addCandidate(candidates, Paths.get(envJavaHome)); + } + } + + /** + * Collects JDK candidates from common tool manager directories under the user's home. + */ + void collectFromToolManagers(Set candidates) { + Path userHome = Paths.get(System.getProperty("user.home")); + + // 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) { + if (Os.IS_WINDOWS) { + collectWindowsDirectories(candidates); + } else if (Os.isFamily("mac")) { + collectMacDirectories(candidates); + } 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) { + Path userHome = Paths.get(System.getProperty("user.home")); + scanSubdirectories(candidates, Paths.get("/Library/Java/JavaVirtualMachines")); + scanSubdirectories( + candidates, userHome.resolve("Library").resolve("Java").resolve("JavaVirtualMachines")); + } + + private void collectWindowsDirectories(Set candidates) { + 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 + Path userHome = Paths.get(System.getProperty("user.home")); + 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/JdkToolchainDiscovererTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java new file mode 100644 index 000000000000..041a9cce1d15 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkToolchainDiscovererTest.java @@ -0,0 +1,180 @@ +/* + * 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.Optional; + +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() { + // Two calls should return the same list instance (cached) + var first = discoverer.discoverToolchains(); + var second = discoverer.discoverToolchains(); + assertNotNull(first); + assertTrue(first == second, "Expected cached result (same instance)"); + } + + 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; + } +} From 2b628ca4e0ba4706cf53f179591900855fc94844 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 11:01:47 +0200 Subject: [PATCH 5/8] Use Session properties instead of System.* in JdkToolchainDiscoverer Replace direct System.getProperty/System.getenv calls with the properties map from Session.getSystemProperties(), where env vars are available as "env.VAR_NAME" entries. This follows Maven's convention of accessing environment through the Session and makes the discoverer fully testable without relying on JVM global state. Co-Authored-By: Claude Opus 4.6 --- .../maven/impl/DefaultToolchainManager.java | 2 +- .../maven/impl/JdkToolchainDiscoverer.java | 77 +++++++++++-------- .../impl/JdkToolchainDiscovererTest.java | 50 +++++++++++- 3 files changed, 95 insertions(+), 34 deletions(-) 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 45df52a465e0..91692529834f 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 @@ -163,7 +163,7 @@ Optional autoSelectJdkToolchain(Session session) { // 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(); + List discoveredModels = discoverer.discoverToolchains(session.getSystemProperties()); List discoveredToolchains = discoveredModels.stream() .map(this::createToolchain) .flatMap(Optional::stream) 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 index 78524a7e6c44..1d7dfe767b0a 100644 --- 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 @@ -49,6 +49,11 @@ *

* 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 @@ -59,15 +64,20 @@ public class JdkToolchainDiscoverer { private volatile List cachedToolchains; /** - * Returns discovered JDK toolchain models. Results are cached after first invocation. + * 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() { + public List discoverToolchains(Map properties) { List result = cachedToolchains; if (result == null) { synchronized (this) { result = cachedToolchains; if (result == null) { - result = doDiscover(); + result = doDiscover(properties); cachedToolchains = result; } } @@ -75,11 +85,11 @@ public List discoverToolchains() { return result; } - private List doDiscover() { + private List doDiscover(Map properties) { Set candidates = new LinkedHashSet<>(); - collectFromEnvironment(candidates); - collectFromToolManagers(candidates); - collectFromSystemDirectories(candidates); + collectFromEnvironment(candidates, properties); + collectFromToolManagers(candidates, properties); + collectFromSystemDirectories(candidates, properties); List toolchains = new ArrayList<>(); for (Path candidate : candidates) { @@ -100,34 +110,33 @@ private List doDiscover() { /** * Collects JDK candidates from environment variables matching {@code JAVA*_HOME}. + * Environment variables are read from the properties map as {@code env.VAR_NAME} entries. */ - void collectFromEnvironment(Set candidates) { - // Current JDK - String javaHome = System.getProperty("java.home"); + 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)); } - // JAVA*_HOME env vars (e.g. JAVA11_HOME, JAVA17_HOME) - for (Map.Entry entry : System.getenv().entrySet()) { + // 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("JAVA") && name.endsWith("_HOME")) { + if (name.startsWith("env.JAVA") && name.endsWith("_HOME")) { addCandidate(candidates, Paths.get(entry.getValue())); } } - - // JAVA_HOME - String envJavaHome = System.getenv("JAVA_HOME"); - if (envJavaHome != null) { - addCandidate(candidates, Paths.get(envJavaHome)); - } } /** * Collects JDK candidates from common tool manager directories under the user's home. */ - void collectFromToolManagers(Set candidates) { - Path userHome = Paths.get(System.getProperty("user.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")); @@ -161,11 +170,11 @@ void collectFromToolManagers(Set candidates) { /** * Collects JDK candidates from OS-specific system directories. */ - void collectFromSystemDirectories(Set candidates) { + void collectFromSystemDirectories(Set candidates, Map properties) { if (Os.IS_WINDOWS) { - collectWindowsDirectories(candidates); + collectWindowsDirectories(candidates, properties); } else if (Os.isFamily("mac")) { - collectMacDirectories(candidates); + collectMacDirectories(candidates, properties); } else { collectLinuxDirectories(candidates); } @@ -181,14 +190,17 @@ private void collectLinuxDirectories(Set candidates) { scanSubdirectories(candidates, Paths.get("/opt/hostedtoolcache")); } - private void collectMacDirectories(Set candidates) { - Path userHome = Paths.get(System.getProperty("user.home")); + private void collectMacDirectories(Set candidates, Map properties) { scanSubdirectories(candidates, Paths.get("/Library/Java/JavaVirtualMachines")); - scanSubdirectories( - candidates, userHome.resolve("Library").resolve("Java").resolve("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) { + 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")); @@ -196,8 +208,11 @@ private void collectWindowsDirectories(Set candidates) { scanSubdirectories(candidates, progFiles.resolve("Amazon Corretto")); scanSubdirectories(candidates, progFiles.resolve("BellSoft")); // Scoop - Path userHome = Paths.get(System.getProperty("user.home")); - scanSubdirectories(candidates, userHome.resolve("scoop").resolve("apps")); + String userHomeProp = properties.get("user.home"); + if (userHomeProp != null) { + Path userHome = Paths.get(userHomeProp); + scanSubdirectories(candidates, userHome.resolve("scoop").resolve("apps")); + } } /** 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 index 041a9cce1d15..76d0d6e576d3 100644 --- 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 @@ -21,7 +21,10 @@ 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; @@ -157,13 +160,56 @@ void resolveJdkHomeInvalidPath() { @Test void discoverToolchainsIsCached() { + Map properties = Map.of("user.home", tempDir.toString()); // Two calls should return the same list instance (cached) - var first = discoverer.discoverToolchains(); - var second = discoverer.discoverToolchains(); + 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"); From 3cd5544a6ac421f63dc05e9b7a7d910735e93871 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 11:56:06 +0200 Subject: [PATCH 6/8] Fix auto-toolchain selection: compiler plugin config, compat layer, and JDK discovery Three fixes for automatic JDK toolchain selection: 1. Read source level from compiler plugin configuration: many projects set and directly in the maven-compiler-plugin block rather than as properties. Added getSourceLevelFromCompilerPlugin() to check both and elements. 2. Pass JdkToolchainDiscoverer to compat layer: ToolchainManagerFactory was creating DefaultToolchainManager without the discoverer, so old plugins going through the compat layer could detect incompatibility but had no discoverer to find alternative JDKs. 3. Scan JAVA_HOME parent directory for sibling JDKs: in CI and container environments, multiple JDKs are often installed as siblings (e.g. /toolchain/jdk-8, /toolchain/jdk-11, /toolchain/jdk-21). Added parent directory scanning from each JAVA*_HOME env var. Co-Authored-By: Claude Opus 4.6 --- .../toolchain/ToolchainManagerFactory.java | 5 +- .../maven/impl/DefaultToolchainManager.java | 53 +++++++++++++++++++ .../maven/impl/JdkToolchainDiscoverer.java | 13 ++++- 3 files changed, 69 insertions(+), 2 deletions(-) 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 91692529834f..890d0f5d1560 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 @@ -37,6 +37,7 @@ 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; @@ -44,6 +45,7 @@ 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; @@ -264,6 +266,57 @@ int getProjectRequiredSourceLevel(Session session) { } } + // 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; } 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 index 1d7dfe767b0a..964441a1edad 100644 --- 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 @@ -111,6 +111,9 @@ private List doDiscover(Map properties) { /** * 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) @@ -123,7 +126,15 @@ void collectFromEnvironment(Set candidates, Map properties for (Map.Entry entry : properties.entrySet()) { String name = entry.getKey(); if (name.startsWith("env.JAVA") && name.endsWith("_HOME")) { - addCandidate(candidates, Paths.get(entry.getValue())); + 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); + } } } } From 617ee27a4ad35a555570838d105cd066dfb94afa Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 11:59:39 +0200 Subject: [PATCH 7/8] Fix DefaultToolchainManager(Map) constructor visibility for IT harness The gh-11055 DI service injection IT calls new DefaultToolchainManager(Map.of()) from outside the package, so the single-arg Map constructor must be public. Co-Authored-By: Claude Opus 4.6 --- .../maven/impl/DefaultToolchainManager.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 890d0f5d1560..df1a9be98e11 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 @@ -62,24 +62,25 @@ public DefaultToolchainManager(Map factories, JdkToolc } /** - * Used for tests only (no discoverer) + * Convenience constructor without a discoverer — auto-selection will skip + * filesystem discovery. Used by tests and IT harnesses. */ - protected DefaultToolchainManager(Map factories) { + public DefaultToolchainManager(Map factories) { this(factories, null, null); } /** - * Used for tests only (no discoverer, custom logger) + * Convenience constructor without a discoverer, with custom logger. + * Used by tests. */ - protected DefaultToolchainManager(Map factories, Logger logger) { + DefaultToolchainManager(Map factories, Logger logger) { this(factories, null, logger); } /** - * Used for tests only (full control) + * Full-control constructor. Used by tests. */ - protected DefaultToolchainManager( - Map factories, JdkToolchainDiscoverer discoverer, Logger logger) { + DefaultToolchainManager(Map factories, JdkToolchainDiscoverer discoverer, Logger logger) { this.factories = factories; this.discoverer = discoverer; this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultToolchainManager.class); From 48d0b6e48fd881eb4320a559ede60bd06ffba146 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 12:04:21 +0200 Subject: [PATCH 8/8] Fix DefaultToolchainManager constructor visibility for compat layer and IT harness The gh-11055 DI service injection IT calls new DefaultToolchainManager(Map.of()) from outside the package, and the compat layer ToolchainManagerFactory calls the 3-arg constructor (factories, discoverer, logger). All constructors must be accessible from outside the package. Co-Authored-By: Claude Opus 4.6 --- .../org/apache/maven/impl/DefaultToolchainManager.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 df1a9be98e11..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 @@ -71,16 +71,18 @@ public DefaultToolchainManager(Map factories) { /** * Convenience constructor without a discoverer, with custom logger. - * Used by tests. + * Used by the compatibility layer and tests. */ - DefaultToolchainManager(Map factories, Logger logger) { + public DefaultToolchainManager(Map factories, Logger logger) { this(factories, null, logger); } /** - * Full-control constructor. Used by tests. + * Full-control constructor with all parameters. + * Used by the compatibility layer ({@code ToolchainManagerFactory}) and tests. */ - DefaultToolchainManager(Map factories, JdkToolchainDiscoverer discoverer, 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);