diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java new file mode 100644 index 000000000000..42ff9250abc5 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java @@ -0,0 +1,347 @@ +/* + * 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.cling.invoker.mvnup.goals; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +import org.apache.maven.api.cli.mvnup.UpgradeOptions; +import org.apache.maven.api.di.Named; +import org.apache.maven.api.di.Priority; +import org.apache.maven.api.di.Singleton; +import org.apache.maven.cling.invoker.mvnup.UpgradeContext; +import org.apache.maven.impl.JdkSourceLevelSupport; + +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.ARTIFACT_ID; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.CONFIGURATION; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.GROUP_ID; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_MANAGEMENT; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROPERTIES; + +/** + * Strategy for adding the {@code maven-toolchains-plugin} with the {@code select-jdk-toolchain} + * goal when the project's required {@code --source}/{@code --release} level is no longer supported + * by the running JDK. + * + *

This strategy detects the project's source level from: + *

    + *
  1. {@code maven.compiler.release} property
  2. + *
  3. {@code maven.compiler.source} property
  4. + *
  5. Compiler plugin {@code } or {@code }
  6. + *
+ * + *

If the running JDK does not support the detected source level (per JEP 182 retirement + * schedule), and the {@code maven-toolchains-plugin} is not already configured with the + * {@code select-jdk-toolchain} goal, this strategy adds it so that the plugin's built-in + * JDK discovery mechanism can find a compatible JDK at build time. + * + * @see JEP 182: Policy for Retiring javac -source and -target Options + */ +@Named +@Singleton +@Priority(15) +public class ToolchainPluginStrategy extends AbstractUpgradeStrategy { + + static final String MAVEN_TOOLCHAINS_PLUGIN = "maven-toolchains-plugin"; + static final String TOOLCHAINS_PLUGIN_GROUP_ID = "org.apache.maven.plugins"; + static final String SELECT_JDK_TOOLCHAIN_GOAL = "select-jdk-toolchain"; + + private static final String MAVEN_COMPILER_RELEASE = "maven.compiler.release"; + private static final String MAVEN_COMPILER_SOURCE = "maven.compiler.source"; + private static final String MAVEN_COMPILER_PLUGIN = "maven-compiler-plugin"; + + @Override + public boolean isApplicable(UpgradeContext context) { + UpgradeOptions options = getOptions(context); + + if (options.all().orElse(false)) { + return true; + } + + // Same default logic as CompatibilityFixStrategy: run when no specific options + boolean noOptionsSpecified = options.all().isEmpty() + && options.infer().isEmpty() + && options.model().isEmpty() + && options.plugins().isEmpty() + && options.modelVersion().isEmpty(); + + if (noOptionsSpecified) { + return true; + } + + // Run when --model is explicitly set + if (options.model().isPresent()) { + return options.model().get(); + } + + return false; + } + + @Override + public String getDescription() { + return "Adding maven-toolchains-plugin for JDK source level compatibility"; + } + + @Override + protected UpgradeResult doApply(UpgradeContext context, Map pomMap) { + Set processedPoms = new HashSet<>(); + Set modifiedPoms = new HashSet<>(); + Set errorPoms = new HashSet<>(); + + int runningJdkMajor = getRunningJdkMajor(); + + for (Map.Entry entry : pomMap.entrySet()) { + Path pomPath = entry.getKey(); + Document pomDocument = entry.getValue(); + processedPoms.add(pomPath); + + context.info(pomPath + " (checking JDK source level compatibility)"); + context.indent(); + + try { + int sourceLevel = detectSourceLevel(pomDocument); + if (sourceLevel <= 0) { + context.success("No source level configured"); + continue; + } + + if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, sourceLevel)) { + context.success("Running JDK " + runningJdkMajor + " supports --source " + sourceLevel); + continue; + } + + if (hasToolchainsPluginWithSelectGoal(pomDocument)) { + context.success( + "maven-toolchains-plugin with " + SELECT_JDK_TOOLCHAIN_GOAL + " goal already configured"); + continue; + } + + int latestJdk = JdkSourceLevelSupport.latestJdkForSourceLevel(sourceLevel); + addToolchainsPlugin(pomDocument, latestJdk); + modifiedPoms.add(pomPath); + context.success("Added maven-toolchains-plugin with " + SELECT_JDK_TOOLCHAIN_GOAL + " goal (--source " + + sourceLevel + " requires JDK <= " + latestJdk + ")"); + } catch (Exception e) { + context.failure("Failed to add toolchains plugin: " + e.getMessage()); + errorPoms.add(pomPath); + } finally { + context.unindent(); + } + } + + return new UpgradeResult(processedPoms, modifiedPoms, errorPoms); + } + + /** + * Detects the project's required source level from properties or compiler plugin configuration. + * + * @return the source level as a major version, or {@code -1} if none is configured + */ + int detectSourceLevel(Document pomDocument) { + Element root = pomDocument.root(); + + // Check properties: maven.compiler.release takes precedence + Element properties = root.childElement(PROPERTIES).orElse(null); + if (properties != null) { + Element releaseElement = + properties.childElement(MAVEN_COMPILER_RELEASE).orElse(null); + if (releaseElement != null) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + releaseElement.textContent().trim()); + if (level > 0) { + return level; + } + } + + Element sourceElement = + properties.childElement(MAVEN_COMPILER_SOURCE).orElse(null); + if (sourceElement != null) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + sourceElement.textContent().trim()); + if (level > 0) { + return level; + } + } + } + + // Check compiler plugin configuration + return detectSourceLevelFromCompilerPlugin(root); + } + + private int detectSourceLevelFromCompilerPlugin(Element root) { + Element build = root.childElement(BUILD).orElse(null); + if (build == null) { + return -1; + } + + // Check + int level = detectFromPluginSection(build.childElement(PLUGINS).orElse(null)); + if (level > 0) { + return level; + } + + // Check + Element pluginManagement = build.childElement(PLUGIN_MANAGEMENT).orElse(null); + if (pluginManagement != null) { + level = detectFromPluginSection( + pluginManagement.childElement(PLUGINS).orElse(null)); + } + + return level; + } + + private int detectFromPluginSection(Element pluginsElement) { + if (pluginsElement == null) { + return -1; + } + + for (Element plugin : pluginsElement.childElements(PLUGIN).toList()) { + String artifactId = plugin.childTextTrimmed(ARTIFACT_ID); + String groupId = plugin.childTextTrimmed(GROUP_ID); + if (MAVEN_COMPILER_PLUGIN.equals(artifactId) + && (groupId == null || groupId.isEmpty() || TOOLCHAINS_PLUGIN_GROUP_ID.equals(groupId))) { + Element config = plugin.childElement(CONFIGURATION).orElse(null); + if (config != null) { + // takes precedence + Element releaseNode = config.childElement("release").orElse(null); + if (releaseNode != null + && releaseNode.textContent() != null + && !releaseNode.textContent().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + releaseNode.textContent().trim()); + if (level > 0) { + return level; + } + } + Element sourceNode = config.childElement("source").orElse(null); + if (sourceNode != null + && sourceNode.textContent() != null + && !sourceNode.textContent().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + sourceNode.textContent().trim()); + if (level > 0) { + return level; + } + } + } + } + } + + return -1; + } + + /** + * Checks whether the POM already has the {@code maven-toolchains-plugin} + * configured with the {@code select-jdk-toolchain} goal. + */ + boolean hasToolchainsPluginWithSelectGoal(Document pomDocument) { + Element root = pomDocument.root(); + Element build = root.childElement(BUILD).orElse(null); + if (build == null) { + return false; + } + + // Check both and + if (hasSelectGoalInPluginSection(build.childElement(PLUGINS).orElse(null))) { + return true; + } + + Element pluginManagement = build.childElement(PLUGIN_MANAGEMENT).orElse(null); + if (pluginManagement != null) { + return hasSelectGoalInPluginSection( + pluginManagement.childElement(PLUGINS).orElse(null)); + } + + return false; + } + + private boolean hasSelectGoalInPluginSection(Element pluginsElement) { + if (pluginsElement == null) { + return false; + } + + for (Element plugin : pluginsElement.childElements(PLUGIN).toList()) { + String artifactId = plugin.childTextTrimmed(ARTIFACT_ID); + String groupId = plugin.childTextTrimmed(GROUP_ID); + if (MAVEN_TOOLCHAINS_PLUGIN.equals(artifactId) + && (groupId == null || groupId.isEmpty() || TOOLCHAINS_PLUGIN_GROUP_ID.equals(groupId))) { + // Check if it has the select-jdk-toolchain goal in any execution + Element executions = plugin.childElement("executions").orElse(null); + if (executions != null) { + for (Element execution : + executions.childElements("execution").toList()) { + Element goals = execution.childElement("goals").orElse(null); + if (goals != null) { + for (Element goal : goals.childElements("goal").toList()) { + if (SELECT_JDK_TOOLCHAIN_GOAL.equals(goal.textContentTrimmed())) { + return true; + } + } + } + } + } + } + } + + return false; + } + + /** + * Adds the {@code maven-toolchains-plugin} with {@code select-jdk-toolchain} goal + * to the POM's {@code } section, configured with a version constraint + * so the plugin selects a JDK that supports the project's source level. + * + * @param pomDocument the POM document to modify + * @param maxJdkVersion the latest JDK major version that supports the source level + */ + void addToolchainsPlugin(Document pomDocument, int maxJdkVersion) { + Element root = pomDocument.root(); + Element build = root.childElement(BUILD).orElse(null); + if (build == null) { + build = DomUtils.insertNewElement(BUILD, root); + } + Element plugins = build.childElement(PLUGINS).orElse(null); + if (plugins == null) { + plugins = DomUtils.insertNewElement(PLUGINS, build); + } + + Element plugin = DomUtils.createPlugin(plugins, TOOLCHAINS_PLUGIN_GROUP_ID, MAVEN_TOOLCHAINS_PLUGIN, null); + Element executions = DomUtils.insertNewElement("executions", plugin); + Element execution = DomUtils.insertNewElement("execution", executions); + Element goals = DomUtils.insertNewElement("goals", execution); + DomUtils.insertContentElement(goals, "goal", SELECT_JDK_TOOLCHAIN_GOAL); + Element configuration = DomUtils.insertNewElement(CONFIGURATION, execution); + DomUtils.insertContentElement(configuration, "version", "(," + maxJdkVersion + "]"); + } + + /** + * Returns the major version of the running JDK. + * Extracted as a method so tests can override it. + */ + int getRunningJdkMajor() { + return JdkSourceLevelSupport.getRunningJdkMajor(); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java new file mode 100644 index 000000000000..7414d262a124 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java @@ -0,0 +1,531 @@ +/* + * 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.cling.invoker.mvnup.goals; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import eu.maveniverse.domtrip.Document; +import org.apache.maven.cling.invoker.mvnup.UpgradeContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +/** + * Unit tests for the {@link ToolchainPluginStrategy} class. + */ +@DisplayName("ToolchainPluginStrategy") +class ToolchainPluginStrategyTest { + + private static final Path POM_PATH = Paths.get("/project/pom.xml"); + + @Nested + @DisplayName("Applicability") + class ApplicabilityTests { + + @Test + @DisplayName("should be applicable when --all option is true") + void applicableWithAll() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy(); + assertTrue(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithAll(true)))); + } + + @Test + @DisplayName("should be applicable with default options (no flags)") + void applicableWithDefaults() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy(); + assertTrue(strategy.isApplicable(TestUtils.createMockContext())); + } + + @Test + @DisplayName("should be applicable when --model is true") + void applicableWithModel() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy(); + assertTrue(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithFixModel(true)))); + } + + @Test + @DisplayName("should not be applicable when --model is false") + void notApplicableWithModelFalse() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy(); + assertFalse(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithFixModel(false)))); + } + } + + @Nested + @DisplayName("Source level detection") + class SourceLevelDetectionTests { + + private ToolchainPluginStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new ToolchainPluginStrategy(); + } + + @Test + @DisplayName("should detect source level from maven.compiler.release property") + void detectFromRelease() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 6 + + + """; + Document doc = Document.of(pomXml); + assertEquals(6, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("should detect source level from maven.compiler.source property") + void detectFromSource() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 1.6 + + + """; + Document doc = Document.of(pomXml); + assertEquals(6, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("release property takes precedence over source property") + void releaseTakesPrecedenceOverSource() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 7 + 6 + + + """; + Document doc = Document.of(pomXml); + assertEquals(7, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("should detect from compiler plugin ") + void detectFromCompilerPluginRelease() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + maven-compiler-plugin + + 6 + + + + + + """; + Document doc = Document.of(pomXml); + assertEquals(6, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("should detect from compiler plugin ") + void detectFromCompilerPluginSource() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + maven-compiler-plugin + + 1.5 + + + + + + """; + Document doc = Document.of(pomXml); + assertEquals(5, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("should return -1 when no source level configured") + void noSourceLevel() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + """; + Document doc = Document.of(pomXml); + assertEquals(-1, strategy.detectSourceLevel(doc)); + } + + @Test + @DisplayName("should detect from pluginManagement") + void detectFromPluginManagement() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + + maven-compiler-plugin + + 6 + + + + + + + """; + Document doc = Document.of(pomXml); + assertEquals(6, strategy.detectSourceLevel(doc)); + } + } + + @Nested + @DisplayName("Toolchains plugin detection") + class ToolchainsPluginDetectionTests { + + private ToolchainPluginStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new ToolchainPluginStrategy(); + } + + @Test + @DisplayName("should detect existing select-jdk-toolchain goal") + void detectExistingGoal() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + org.apache.maven.plugins + maven-toolchains-plugin + + + + select-jdk-toolchain + + + + + + + + """; + Document doc = Document.of(pomXml); + assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc)); + } + + @Test + @DisplayName("should not detect toolchains plugin without select-jdk-toolchain goal") + void noSelectGoal() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + org.apache.maven.plugins + maven-toolchains-plugin + + + + toolchain + + + + + + + + """; + Document doc = Document.of(pomXml); + assertFalse(strategy.hasToolchainsPluginWithSelectGoal(doc)); + } + + @Test + @DisplayName("should not detect when no toolchains plugin present") + void noToolchainsPlugin() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + """; + Document doc = Document.of(pomXml); + assertFalse(strategy.hasToolchainsPluginWithSelectGoal(doc)); + } + } + + @Nested + @DisplayName("Plugin addition") + class PluginAdditionTests { + + private ToolchainPluginStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new ToolchainPluginStrategy(); + } + + @Test + @DisplayName("should add toolchains plugin with version constraint to POM without build section") + void addToEmptyPom() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + """; + Document doc = Document.of(pomXml); + strategy.addToolchainsPlugin(doc, 11); + + assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc)); + String output = doc.toXml(); + assertTrue(output.contains("(,11]"), "Expected version constraint in output: " + output); + } + + @Test + @DisplayName("should add toolchains plugin with version constraint to POM with existing build section") + void addToExistingBuild() { + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + + + maven-compiler-plugin + + + + + """; + Document doc = Document.of(pomXml); + strategy.addToolchainsPlugin(doc, 8); + + assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc)); + String output = doc.toXml(); + assertTrue(output.contains("(,8]"), "Expected version constraint in output: " + output); + } + } + + @Nested + @DisplayName("Full apply") + class ApplyTests { + + @Test + @DisplayName("should add plugin when source level is incompatible with running JDK") + void addsPluginWhenIncompatible() { + // Simulate running JDK 21, project targets source 6 + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() { + @Override + int getRunningJdkMajor() { + return 21; + } + }; + + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 6 + + + """; + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(); + + UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc)); + + assertEquals(1, result.modifiedPoms().size()); + assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc)); + } + + @Test + @DisplayName("should not modify POM when source level is compatible") + void noModificationWhenCompatible() { + // Simulate running JDK 17, project targets source 11 + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() { + @Override + int getRunningJdkMajor() { + return 17; + } + }; + + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 11 + + + """; + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(); + + UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc)); + + assertEquals(0, result.modifiedPoms().size()); + } + + @Test + @DisplayName("should not modify POM when no source level configured") + void noModificationWithoutSourceLevel() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() { + @Override + int getRunningJdkMajor() { + return 21; + } + }; + + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + """; + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(); + + UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc)); + + assertEquals(0, result.modifiedPoms().size()); + } + + @Test + @DisplayName("should not add duplicate plugin when already present") + void noDuplicatePlugin() { + ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() { + @Override + int getRunningJdkMajor() { + return 21; + } + }; + + String pomXml = """ + + + 4.0.0 + com.example + test + 1.0 + + 6 + + + + + org.apache.maven.plugins + maven-toolchains-plugin + + + + select-jdk-toolchain + + + + + + + + """; + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(); + + UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc)); + + assertEquals(0, result.modifiedPoms().size()); + } + } +} 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..73a0e2db3599 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 @@ -35,12 +35,16 @@ import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; +import org.apache.maven.api.model.Build; +import org.apache.maven.api.model.Plugin; +import org.apache.maven.api.model.Source; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.ToolchainFactory; import org.apache.maven.api.services.ToolchainFactoryException; import org.apache.maven.api.services.ToolchainManager; import org.apache.maven.api.services.ToolchainManagerException; import org.apache.maven.api.toolchain.ToolchainModel; +import org.apache.maven.api.xml.XmlNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,13 +56,13 @@ public class DefaultToolchainManager implements ToolchainManager { @Inject public DefaultToolchainManager(Map factories) { - this(factories, null); + this(factories, (Logger) null); } /** - * Used for tests only + * Constructor with custom logger. Used by the compatibility layer and tests. */ - protected DefaultToolchainManager(Map factories, Logger logger) { + public DefaultToolchainManager(Map factories, Logger logger) { this.factories = factories; this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultToolchainManager.class); } @@ -89,7 +93,17 @@ 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, check if the running JDK supports the project's source level + // and emit a clear, actionable error if not + if ("jdk".equals(type)) { + checkJdkSourceLevelCompatibility(session); + } + + return Optional.empty(); } @Override @@ -98,6 +112,154 @@ public void storeToolchainToBuildContext(@Nonnull Session session, @Nonnull Tool context.put("toolchain-" + toolchain.getType(), toolchain.getModel()); } + /** + * Checks whether the running JDK supports the project's required {@code --source}/{@code --release} + * level. If not, emits a clear, actionable error message instead of letting the build fail later + * with a cryptic javac error. + *

+ * The error tells the user exactly which JDK version they need and how to fix it + * (run {@code mvnup} to add the {@code maven-toolchains-plugin} with automatic JDK discovery). + */ + void checkJdkSourceLevelCompatibility(Session session) { + int requiredSourceLevel = getProjectRequiredSourceLevel(session); + if (requiredSourceLevel <= 0) { + return; + } + + int runningJdkMajor = getRunningJdkMajor(); + if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, requiredSourceLevel)) { + return; + } + + // Running JDK is incompatible — emit clear, actionable error + int latestJdk = JdkSourceLevelSupport.latestJdkForSourceLevel(requiredSourceLevel); + logger.error( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.", + requiredSourceLevel, + latestJdk, + runningJdkMajor); + logger.error("To fix: run 'mvnup' to add the maven-toolchains-plugin with automatic JDK discovery,"); + logger.error( + "or install JDK {} and configure it in toolchains.xml or via the maven-toolchains-plugin.", latestJdk); + } + + /** + * Reads the project's required source level from Model 4.1.0 + * {@code } elements, legacy properties + * ({@code maven.compiler.release}, {@code maven.compiler.source}), + * or compiler plugin configuration ({@code }, {@code }). + * + * @return the required source level as a major version, or {@code -1} if none is specified + */ + int getProjectRequiredSourceLevel(Session session) { + Optional current = session.getService(Lookup.class).lookupOptional(Project.class); + if (current.isEmpty()) { + return -1; + } + + Project project = current.get(); + + // Check Model 4.1.0 elements + Build build = project.getModel().getBuild(); + if (build != null) { + List sources = build.getSources(); + if (sources != null) { + for (Source source : sources) { + String targetVersion = source.getTargetVersion(); + if (targetVersion != null && !targetVersion.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(targetVersion); + if (level > 0) { + return level; + } + } + } + } + } + + // Fall back to legacy properties + Map properties = project.getModel().getProperties(); + if (properties != null) { + // maven.compiler.release takes precedence + String release = properties.get("maven.compiler.release"); + if (release != null && !release.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(release); + if (level > 0) { + return level; + } + } + + // Then maven.compiler.source + String source = properties.get("maven.compiler.source"); + if (source != null && !source.isEmpty()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel(source); + if (level > 0) { + return level; + } + } + } + + // Fall back to compiler plugin configuration (, ) + int pluginLevel = getSourceLevelFromCompilerPlugin(build); + if (pluginLevel > 0) { + return pluginLevel; + } + + return -1; + } + + /** + * Reads the source level from the maven-compiler-plugin configuration. + * Checks both {@code } and {@code } elements in the plugin's + * {@code } block. + * + * @return the source level, or {@code -1} if not configured + */ + private int getSourceLevelFromCompilerPlugin(Build build) { + if (build == null) { + return -1; + } + for (Plugin plugin : build.getPlugins()) { + if ("maven-compiler-plugin".equals(plugin.getArtifactId()) + && (plugin.getGroupId() == null + || plugin.getGroupId().isEmpty() + || "org.apache.maven.plugins".equals(plugin.getGroupId()))) { + XmlNode config = plugin.getConfiguration(); + if (config != null) { + // takes precedence over + XmlNode releaseNode = config.child("release"); + if (releaseNode != null + && releaseNode.value() != null + && !releaseNode.value().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + releaseNode.value().trim()); + if (level > 0) { + return level; + } + } + XmlNode sourceNode = config.child("source"); + if (sourceNode != null + && sourceNode.value() != null + && !sourceNode.value().isBlank()) { + int level = JdkSourceLevelSupport.normalizeSourceLevel( + sourceNode.value().trim()); + if (level > 0) { + return level; + } + } + } + } + } + return -1; + } + + /** + * Returns the major version of the running JDK. + * Extracted as a method so tests can override it. + */ + int getRunningJdkMajor() { + return JdkSourceLevelSupport.getRunningJdkMajor(); + } + private Optional createToolchain(ToolchainModel model) { String type = Objects.requireNonNull(model.getType(), "model.getType()"); ToolchainFactory factory = factories.get(type); diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java new file mode 100644 index 000000000000..7571829a7da4 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java @@ -0,0 +1,162 @@ +/* + * 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: + *

+ */ +public 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 + */ + public 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 + */ + public 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: + *

+ * + * @param version the source level string + * @return the normalized major version, or {@code -1} if the string cannot be parsed + */ + public static int normalizeSourceLevel(String version) { + if (version == null || version.isEmpty()) { + return -1; + } + version = version.trim(); + // Handle "1.x" legacy format (e.g., "1.5", "1.8", "1.8.0_392") + if (version.startsWith("1.") && version.length() > 2) { + String rest = version.substring(2); + // Strip any trailing qualifiers (e.g. "8.0_392" → "8") + int sep = indexOfNonDigit(rest); + if (sep > 0) { + rest = rest.substring(0, sep); + } + try { + return Integer.parseInt(rest); + } catch (NumberFormatException e) { + return -1; + } + } + // Handle dotted versions like "21.0.1" — take the first segment + int dotIndex = version.indexOf('.'); + if (dotIndex > 0) { + version = version.substring(0, dotIndex); + } + try { + return Integer.parseInt(version); + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * Returns the index of the first non-digit character in the string, or -1 if all characters are digits. + */ + private static int indexOfNonDigit(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return i; + } + } + return -1; + } + + /** + * Returns the latest JDK major version that still supports the given {@code --source} level. + *

+ * Based on the retirement schedule: + *

+ * + * @param sourceLevel the source level + * @return the latest JDK major version that supports it, or {@code -1} if the source level + * is still supported by all current JDKs + */ + public static int latestJdkForSourceLevel(int sourceLevel) { + if (sourceLevel <= 5) { + return 8; + } + if (sourceLevel == 6) { + return 11; + } + if (sourceLevel == 7) { + return 20; + } + return -1; // still supported + } + + /** + * Returns the major version of the currently running JDK. + * + * @return the running JDK major version + */ + public 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..b471453c1b48 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 @@ -27,6 +27,9 @@ import org.apache.maven.api.Session; import org.apache.maven.api.SessionData; import org.apache.maven.api.Toolchain; +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 +38,16 @@ 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.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -123,4 +130,269 @@ void retrieveContextWithoutProject() { void getToolchainsWithNullType() { assertThrows(NullPointerException.class, () -> manager.getToolchains(session, null, null)); } + + // --- Source level compatibility check tests --- + + @Test + void checkCompatibilityNoTargetVersion() { + // Project has no targetVersion configured — no error + 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().build()).build(); + when(project.getModel()).thenReturn(model); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger, never()).error(any(String.class), any(), any(), any()); + } + + @Test + void checkCompatibilityRunningJdkSupportsLevel() { + // Project targets source 11, running JDK 17 supports it — no error + 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("11").build())) + .build()) + .build(); + when(project.getModel()).thenReturn(model); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger, never()).error(any(String.class), any(), any(), any()); + } + + @Test + void checkCompatibilityEmitsErrorWhenIncompatible() { + // Project targets source 6, running JDK 17 doesn't support it — should emit error + 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); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger) + .error( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.", + 6, + 11, + 17); + } + + @Test + void checkCompatibilityEmitsErrorForSource5() { + // Project targets source 5, running JDK 21 — max JDK is 8 + Logger testLogger = mock(Logger.class); + DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) { + @Override + int getRunningJdkMajor() { + return 21; + } + }; + + 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); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger) + .error( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.", + 5, + 8, + 21); + } + + @Test + void checkCompatibilityFromLegacyProperties() { + // Project uses maven.compiler.release=6, 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); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger) + .error( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.", + 6, + 11, + 17); + } + + @Test + void checkCompatibilityFromLegacySourceProperty() { + // Project uses maven.compiler.source=1.6, 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); + + testManager.checkJdkSourceLevelCompatibility(session); + + verify(testLogger) + .error( + eq( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it."), + eq(6), + eq(11), + eq(17)); + } + + @Test + void getToolchainFromBuildContextChecksCompatibility() { + // Verify getToolchainFromBuildContext calls compatibility check for jdk type + 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); + + Optional result = testManager.getToolchainFromBuildContext(session, "jdk"); + + // Should return empty (no auto-selection) but emit error + assertTrue(result.isEmpty()); + verify(testLogger) + .error( + "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.", + 6, + 11, + 17); + } + + @Test + void getToolchainFromBuildContextReturnsExplicitToolchain() { + // When an explicit toolchain is stored via storeToolchainToBuildContext, + // it takes precedence — no compatibility check needed + 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); + + manager.storeToolchainToBuildContext(session, mockToolchain); + Optional result = manager.getToolchainFromBuildContext(session, "jdk"); + + assertTrue(result.isPresent()); + assertEquals(mockToolchain, result.get()); + } + + @Test + void getToolchainFromBuildContextNonJdkTypeNoCheck() { + // Compatibility check 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); + + Optional result = manager.getToolchainFromBuildContext(session, "otherType"); + assertTrue(result.isEmpty()); + } + + @Test + void getProjectRequiredSourceLevelTargetVersionTakesPrecedence() { + // targetVersion in sources should take precedence over properties + 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, manager.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..a93a37793b66 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java @@ -0,0 +1,178 @@ +/* + * 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 normalizeSourceLevelLegacyFormatWithPatchVersion() { + // "1.8.0_392" format from some JDK distributions + assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("1.8.0_392")); + assertEquals(7, JdkSourceLevelSupport.normalizeSourceLevel("1.7.0_80")); + assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("1.6.0_45")); + } + + @Test + void latestJdkForSourceLevelRetiredLevels() { + // source 1-5 → last supported by JDK 8 + assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(1)); + assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(3)); + assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(5)); + } + + @Test + void latestJdkForSourceLevel6() { + // source 6 → last supported by JDK 11 + assertEquals(11, JdkSourceLevelSupport.latestJdkForSourceLevel(6)); + } + + @Test + void latestJdkForSourceLevel7() { + // source 7 → last supported by JDK 20 + assertEquals(20, JdkSourceLevelSupport.latestJdkForSourceLevel(7)); + } + + @Test + void latestJdkForSourceLevelStillSupported() { + // source 8+ → still supported by current JDKs + assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(8)); + assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(11)); + assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(17)); + assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(21)); + } + + @Test + void getRunningJdkMajorReturnsPositive() { + assertTrue(JdkSourceLevelSupport.getRunningJdkMajor() > 0); + } +} diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml index 5871a3570f4e..4ae52e82eeae 100644 --- a/its/core-it-suite/pom.xml +++ b/its/core-it-suite/pom.xml @@ -519,7 +519,7 @@ under the License. - + @@ -749,7 +749,7 @@ under the License. - + @@ -804,7 +804,7 @@ under the License. - + diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java new file mode 100644 index 000000000000..718b6db66d8d --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java @@ -0,0 +1,88 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the {@code ToolchainPluginStrategy} in {@code mvnup}. + *

+ * Verifies that running {@code mvnup apply} on a project with an old + * {@code --source} level (unsupported by the running JDK) automatically adds + * the {@code maven-toolchains-plugin} with the {@code select-jdk-toolchain} + * goal and the correct {@code } constraint, and that a second run + * is idempotent. + * + * @since 4.1.0 + */ +class MavenITMvnupToolchainPluginStrategyTest extends AbstractMavenIntegrationTestCase { + + /** + * Verify that mvnup adds the maven-toolchains-plugin with select-jdk-toolchain + * goal and version constraint when the project's source level is unsupported + * by the running JDK, and that a second run is idempotent. + */ + @Test + void testMvnupAddsToolchainsPluginForOldSourceLevel() throws Exception { + Path testDir = extractResources("mvnup-toolchain-plugin-strategy"); + + // First run — should add the plugin + Verifier verifier = newVerifier(testDir); + verifier.setForkJvm(true); + verifier.setLogFileName("first-run.txt"); + verifier.setExecutable("mvnup"); + verifier.addCliArgument("apply"); + verifier.addCliArgument("-d"); + verifier.addCliArgument(testDir.toString()); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify mvnup reported adding the plugin + verifier.verifyTextInLog("Added maven-toolchains-plugin with select-jdk-toolchain goal"); + + // Verify the POM was modified to include the toolchains plugin + String pomContent = Files.readString(testDir.resolve("pom.xml")); + assertTrue( + pomContent.contains("maven-toolchains-plugin"), + "POM should contain maven-toolchains-plugin after mvnup apply"); + assertTrue( + pomContent.contains("select-jdk-toolchain"), + "POM should contain select-jdk-toolchain goal after mvnup apply"); + assertTrue( + pomContent.contains("(,8]"), + "POM should contain version constraint (,8] for source 5 after mvnup apply"); + + // Second run — should be idempotent (skip, already present) + verifier = newVerifier(testDir); + verifier.setForkJvm(true); + verifier.setLogFileName("second-run.txt"); + verifier.setExecutable("mvnup"); + verifier.addCliArgument("apply"); + verifier.addCliArgument("-d"); + verifier.addCliArgument(testDir.toString()); + verifier.execute(); + verifier.verifyErrorFreeLog(); + verifier.verifyTextInLog("maven-toolchains-plugin with select-jdk-toolchain goal already configured"); + } +} diff --git a/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml new file mode 100644 index 000000000000..6fa0b3dc18ac --- /dev/null +++ b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + com.example + old-source-test + 1.0-SNAPSHOT + + 1.5 + 1.5 + +