gnodet-bot commented on code in PR #13636: URL: https://github.com/apache/maven/pull/13636#discussion_r4483189311
########## impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java: ########## @@ -0,0 +1,415 @@ +/* + * 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.plugin.internal; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.plugin.PluginConfigurationException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; + +/** Configures a Maven 4 mojo directly from the public Maven XML model. */ +public final class V4MojoConfigurator { + private static final Object NOT_SCALAR = new Object(); + + private final MojoExpressionEvaluator evaluator; + private final ClassLoader classLoader; + private final PluginDescriptor pluginDescriptor; + + public V4MojoConfigurator( + MojoExpressionEvaluator evaluator, ClassLoader classLoader, PluginDescriptor pluginDescriptor) { + this.evaluator = evaluator; + this.classLoader = classLoader; + this.pluginDescriptor = pluginDescriptor; + } + + public void configure(Object mojo, XmlNode configuration) throws PluginConfigurationException { + if (configuration == null) { + return; + } + for (XmlNode child : configuration.children()) { + try { + set(mojo, child.name(), child); + } catch (Exception e) { + throw new PluginConfigurationException( + pluginDescriptor, + "Unable to configure '" + child.name() + "' on " + + mojo.getClass().getName(), + e); + } + } + } + + private void set(Object bean, String name, XmlNode node) throws Exception { + if (name.isEmpty()) { + throw new NoSuchFieldException("<empty element name>"); + } + String title = Character.toUpperCase(name.charAt(0)) + name.substring(1); + for (Method method : bean.getClass().getMethods()) { + if ((method.getName().equals("set" + title) || method.getName().equals("add" + title)) + && method.getParameterCount() == 1) { + if (!method.canAccess(bean)) { + method.setAccessible(true); + } + method.invoke(bean, convert(node, method.getGenericParameterTypes()[0])); + return; + } + } + for (Class<?> type = bean.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(bean, convert(node, field.getGenericType())); + return; + } catch (NoSuchFieldException ignored) { + // Continue through the class hierarchy. + } + } + throw new NoSuchFieldException(name); + } + + private Object convert(XmlNode node, Type target) throws Exception { + Class<?> type = rawType(target); + if (type == XmlNode.class) { + return node; + } + Object scalar = convertScalar(node, type); + if (scalar != NOT_SCALAR) { + return scalar; + } + if (type.isArray()) { + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + String[] parts = text != null ? text.split(",", -1) : new String[0]; + Object array = Array.newInstance(type.getComponentType(), parts.length); Review Comment: ⚠️ **`split(",", -1)` preserves empty tokens — `NumberFormatException` for numeric array types.** `"a,,b".split(",", -1)` → `["a", "", "b"]`. `.trim()` removes whitespace but not empty strings. For `int[]` or `List<Integer>`, the empty-string token causes `NumberFormatException` in the downstream `Integer.valueOf()` call. Same issue on line 139 for collections. ```suggestion String[] parts = text != null ? java.util.Arrays.stream(text.split(",", -1)) .map(String::trim) .filter(s -> !s.isEmpty()) .toArray(String[]::new) : new String[0]; ``` ########## impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java: ########## @@ -0,0 +1,415 @@ +/* + * 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.plugin.internal; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.plugin.PluginConfigurationException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; + +/** Configures a Maven 4 mojo directly from the public Maven XML model. */ +public final class V4MojoConfigurator { + private static final Object NOT_SCALAR = new Object(); + + private final MojoExpressionEvaluator evaluator; + private final ClassLoader classLoader; + private final PluginDescriptor pluginDescriptor; + + public V4MojoConfigurator( + MojoExpressionEvaluator evaluator, ClassLoader classLoader, PluginDescriptor pluginDescriptor) { + this.evaluator = evaluator; + this.classLoader = classLoader; + this.pluginDescriptor = pluginDescriptor; + } + + public void configure(Object mojo, XmlNode configuration) throws PluginConfigurationException { + if (configuration == null) { + return; + } + for (XmlNode child : configuration.children()) { + try { + set(mojo, child.name(), child); + } catch (Exception e) { + throw new PluginConfigurationException( + pluginDescriptor, + "Unable to configure '" + child.name() + "' on " + + mojo.getClass().getName(), + e); + } + } + } + + private void set(Object bean, String name, XmlNode node) throws Exception { + if (name.isEmpty()) { + throw new NoSuchFieldException("<empty element name>"); + } + String title = Character.toUpperCase(name.charAt(0)) + name.substring(1); + for (Method method : bean.getClass().getMethods()) { + if ((method.getName().equals("set" + title) || method.getName().equals("add" + title)) + && method.getParameterCount() == 1) { + if (!method.canAccess(bean)) { + method.setAccessible(true); + } + method.invoke(bean, convert(node, method.getGenericParameterTypes()[0])); + return; + } + } + for (Class<?> type = bean.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(bean, convert(node, field.getGenericType())); + return; + } catch (NoSuchFieldException ignored) { + // Continue through the class hierarchy. + } + } + throw new NoSuchFieldException(name); + } + + private Object convert(XmlNode node, Type target) throws Exception { + Class<?> type = rawType(target); + if (type == XmlNode.class) { + return node; + } + Object scalar = convertScalar(node, type); + if (scalar != NOT_SCALAR) { + return scalar; + } + if (type.isArray()) { + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + String[] parts = text != null ? text.split(",", -1) : new String[0]; + Object array = Array.newInstance(type.getComponentType(), parts.length); + for (int i = 0; i < parts.length; i++) { + Array.set(array, i, convert(XmlNode.newInstance("item", parts[i].trim()), type.getComponentType())); + } + return array; + } + Object array = Array.newInstance(type.getComponentType(), children.size()); + for (int i = 0; i < children.size(); i++) { + Array.set(array, i, convert(children.get(i), type.getComponentType())); + } + return array; + } + if (Collection.class.isAssignableFrom(type)) { + Type itemType = target instanceof ParameterizedType p ? p.getActualTypeArguments()[0] : Object.class; + Collection<Object> values = + Set.class.isAssignableFrom(type) ? new java.util.LinkedHashSet<>() : new ArrayList<>(); + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + if (text != null) { + for (String part : text.split(",", -1)) { + values.add(convert(XmlNode.newInstance("item", part.trim()), itemType)); + } + } + } else { + for (XmlNode child : children) { + values.add(convert(child, itemType)); + } + } + return values; + } + if (Map.class.isAssignableFrom(type)) { + Type valueType = target instanceof ParameterizedType p ? p.getActualTypeArguments()[1] : Object.class; + if (Properties.class.isAssignableFrom(type)) { + Properties properties = new Properties(); + for (XmlNode child : node.children()) { + // Support Plexus-style: <property><name>k</name><value>v</value></property> + XmlNode nameNode = child.child("name"); + XmlNode valueNode = child.child("value"); + if (nameNode != null && valueNode != null) { + String key = evaluateNodeAsString(nameNode); + Object val = convert(valueNode, valueType); + if (key != null && val != null) { + properties.put(key, val); + } + } else { + // Support key=element-name, value=text: <key>value</key> + Object val = convert(child, valueType); + if (val != null) { + properties.put(child.name(), val); + } + } + } + return properties; + } + Map<String, Object> values = new LinkedHashMap<>(); + for (XmlNode child : node.children()) { + values.put(child.name(), convert(child, valueType)); + } + return values; + } + type = resolveImplementation(node, type); + String value = evaluateNodeAsString(node); + if (value != null && node.children().isEmpty()) { + Method setter = findDefaultSetter(type); + if (setter != null) { + Object instance = instantiate(type); + if (!setter.canAccess(instance)) { + setter.setAccessible(true); + } + setter.invoke( + instance, convert(XmlNode.newInstance("value", value), setter.getGenericParameterTypes()[0])); + return instance; + } + } + if (type.isRecord()) { + return instantiateRecord(node, type); + } + Object instance = instantiate(type); + for (XmlNode child : node.children()) { + set(instance, child.name(), child); + } + return instance; + } + + private Object convertScalar(XmlNode node, Class<?> type) throws Exception { + if (type == String.class || type == Object.class) { + return evaluateNode(node, type); + } Review Comment: ❌ **`Object.class` still treated as scalar — silently drops XML children.** `evaluateNode` returns `null` when the node has children but no text content. Back in `convert()`, `null != NOT_SCALAR` → returns `null` immediately, skipping the array/collection/map/bean branches. Any `Object`-typed parameter (e.g. `Map<String, Object>`) with nested XML children will silently resolve to `null`. ```suggestion if (type == String.class) { return evaluateNode(node, type); } if (type == Object.class) { return node.children().isEmpty() ? evaluateNode(node, type) : NOT_SCALAR; } ``` ########## impl/maven-core/src/test/java/org/apache/maven/plugin/internal/V4MojoConfiguratorTest.java: ########## @@ -0,0 +1,430 @@ +/* + * 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.plugin.internal; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.internal.xml.XmlNodeImpl; +import org.apache.maven.plugin.PluginConfigurationException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class V4MojoConfiguratorTest { + + private MojoExpressionEvaluator evaluator; + private PluginDescriptor pluginDescriptor; + private V4MojoConfigurator configurator; + + @BeforeEach + void setUp() throws Exception { + evaluator = mock(MojoExpressionEvaluator.class); + pluginDescriptor = new PluginDescriptor(); + configurator = new V4MojoConfigurator(evaluator, getClass().getClassLoader(), pluginDescriptor); + + when(evaluator.evaluate(anyString(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(evaluator.alignToBaseDirectory(any(File.class))).thenAnswer(invocation -> { + File f = invocation.getArgument(0); + return f.isAbsolute() ? f : new File(new File("base"), f.getPath()).getAbsoluteFile(); + }); + } + + public enum Status { + ACTIVE, + INACTIVE + } + + public static class ScalarMojo { + String str; + int primitiveInt; + Integer boxedInt; + long primitiveLong; + Long boxedLong; + double primitiveDouble; + Double boxedDouble; + float primitiveFloat; + Float boxedFloat; + short primitiveShort; + Short boxedShort; + byte primitiveByte; + Byte boxedByte; + boolean primitiveBool; + Boolean boxedBool; + char primitiveChar; + Character boxedChar; + Status status; + File file; + Path path; + } + + @Test + void testScalarTypesConfiguration() throws Exception { + ScalarMojo mojo = new ScalarMojo(); + XmlNode config = new XmlNodeImpl( + "configuration", + null, + Map.of(), + List.of( + new XmlNodeImpl("str", "hello"), + new XmlNodeImpl("primitiveInt", "42"), + new XmlNodeImpl("boxedInt", "43"), + new XmlNodeImpl("primitiveLong", "100"), + new XmlNodeImpl("boxedLong", "101"), + new XmlNodeImpl("primitiveDouble", "3.14"), + new XmlNodeImpl("boxedDouble", "2.71"), + new XmlNodeImpl("primitiveFloat", "1.5"), + new XmlNodeImpl("boxedFloat", "2.5"), + new XmlNodeImpl("primitiveShort", "10"), + new XmlNodeImpl("boxedShort", "11"), + new XmlNodeImpl("primitiveByte", "1"), + new XmlNodeImpl("boxedByte", "2"), + new XmlNodeImpl("primitiveBool", "true"), + new XmlNodeImpl("boxedBool", "false"), + new XmlNodeImpl("primitiveChar", "X"), + new XmlNodeImpl("boxedChar", "Y"), + new XmlNodeImpl("status", "ACTIVE"), + new XmlNodeImpl("file", "relative/file.txt"), + new XmlNodeImpl("path", "relative/path.txt")), + null); + + configurator.configure(mojo, config); + + assertEquals("hello", mojo.str); + assertEquals(42, mojo.primitiveInt); + assertEquals(43, mojo.boxedInt); + assertEquals(100L, mojo.primitiveLong); + assertEquals(101L, mojo.boxedLong); + assertEquals(3.14, mojo.primitiveDouble, 0.001); + assertEquals(2.71, mojo.boxedDouble, 0.001); + assertEquals(1.5f, mojo.primitiveFloat, 0.001f); + assertEquals(2.5f, mojo.boxedFloat, 0.001f); + assertEquals((short) 10, mojo.primitiveShort); + assertEquals((short) 11, mojo.boxedShort); + assertEquals((byte) 1, mojo.primitiveByte); + assertEquals((byte) 2, mojo.boxedByte); + assertTrue(mojo.primitiveBool); + assertFalse(mojo.boxedBool); + assertEquals('X', mojo.primitiveChar); + assertEquals('Y', mojo.boxedChar); + assertEquals(Status.ACTIVE, mojo.status); + assertEquals(new File(new File("base"), "relative/file.txt").getAbsoluteFile(), mojo.file); + assertEquals(Paths.get(new File(new File("base"), "relative/path.txt").getAbsolutePath()), mojo.path); + } + + @Test + void testUnresolvedExpressionsDoNotThrowAndUseDefaults() throws Exception { + MojoExpressionEvaluator nullEvaluator = mock(MojoExpressionEvaluator.class); + when(nullEvaluator.evaluate(anyString(), any())).thenReturn(null); + V4MojoConfigurator nullConfigurator = + new V4MojoConfigurator(nullEvaluator, getClass().getClassLoader(), pluginDescriptor); + + ScalarMojo mojo = new ScalarMojo(); + XmlNode config = new XmlNodeImpl( + "configuration", + null, + Map.of(), + List.of( + new XmlNodeImpl("str", "${unresolved.str}"), + new XmlNodeImpl("boxedInt", "${unresolved.int}"), + new XmlNodeImpl("primitiveInt", "${unresolved.int}"), + new XmlNodeImpl("boxedChar", "${unresolved.char}"), + new XmlNodeImpl("primitiveChar", "${unresolved.char}"), + new XmlNodeImpl("status", "${unresolved.status}"), + new XmlNodeImpl("file", "${unresolved.file}")), + null); + + nullConfigurator.configure(mojo, config); + + assertNull(mojo.str); + assertNull(mojo.boxedInt); + assertEquals(0, mojo.primitiveInt); + assertNull(mojo.boxedChar); + assertEquals('\0', mojo.primitiveChar); + assertNull(mojo.status); + assertNull(mojo.file); + } + + @Test + void testDefaultValueAttributeFallback() throws Exception { + MojoExpressionEvaluator nullEvaluator = mock(MojoExpressionEvaluator.class); + when(nullEvaluator.evaluate(anyString(), any())).thenAnswer(inv -> { + String val = inv.getArgument(0); + return val.startsWith("${unresolved") ? null : val; + }); + V4MojoConfigurator fallbackConfigurator = + new V4MojoConfigurator(nullEvaluator, getClass().getClassLoader(), pluginDescriptor); + + ScalarMojo mojo = new ScalarMojo(); + XmlNode config = new XmlNodeImpl( + "configuration", + null, + Map.of(), + List.of( + new XmlNodeImpl("str", null, Map.of("default-value", "fallback-str"), List.of(), null), + new XmlNodeImpl( + "primitiveInt", "${unresolved}", Map.of("default-value", "99"), List.of(), null), + new XmlNodeImpl("primitiveChar", null, Map.of("default-value", "Z"), List.of(), null), + new XmlNodeImpl("primitiveBool", null, Map.of("default-value", "true"), List.of(), null)), + null); + + fallbackConfigurator.configure(mojo, config); + + assertEquals("fallback-str", mojo.str); + assertEquals(99, mojo.primitiveInt); + assertEquals('Z', mojo.primitiveChar); + assertTrue(mojo.primitiveBool); + } + + public static class CollectionsMojo { + String[] array; + List<String> list; + Set<String> set; + Map<String, String> map; + Properties properties; + } + + @Test + void testCollectionsAndMaps() throws Exception { Review Comment: 📋 **Missing tests for two new code paths added to `testCollectionsAndMaps`.** `testCollectionsAndMaps` only exercises the child-element XML path. Neither of the two new code paths from the previous commit has a direct unit test: 1. **Comma-separated splitting** (`children.isEmpty()` branch): e.g. `new XmlNodeImpl("array", "a,b,c")` → `["a", "b", "c"]`. Without this, the fix for empty-token filtering (above) would also have no coverage. 2. **Plexus-style `<property><name>k</name><value>v</value></property>` format**: the new Plexus-detection branch in the Properties handling code is untested. Please add targeted test cases for both paths. ########## impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java: ########## @@ -0,0 +1,415 @@ +/* + * 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.plugin.internal; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.plugin.PluginConfigurationException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; + +/** Configures a Maven 4 mojo directly from the public Maven XML model. */ +public final class V4MojoConfigurator { + private static final Object NOT_SCALAR = new Object(); + + private final MojoExpressionEvaluator evaluator; + private final ClassLoader classLoader; + private final PluginDescriptor pluginDescriptor; + + public V4MojoConfigurator( + MojoExpressionEvaluator evaluator, ClassLoader classLoader, PluginDescriptor pluginDescriptor) { + this.evaluator = evaluator; + this.classLoader = classLoader; + this.pluginDescriptor = pluginDescriptor; + } + + public void configure(Object mojo, XmlNode configuration) throws PluginConfigurationException { + if (configuration == null) { + return; + } + for (XmlNode child : configuration.children()) { + try { + set(mojo, child.name(), child); + } catch (Exception e) { + throw new PluginConfigurationException( + pluginDescriptor, + "Unable to configure '" + child.name() + "' on " + + mojo.getClass().getName(), + e); + } + } + } + + private void set(Object bean, String name, XmlNode node) throws Exception { + if (name.isEmpty()) { + throw new NoSuchFieldException("<empty element name>"); + } + String title = Character.toUpperCase(name.charAt(0)) + name.substring(1); + for (Method method : bean.getClass().getMethods()) { + if ((method.getName().equals("set" + title) || method.getName().equals("add" + title)) + && method.getParameterCount() == 1) { + if (!method.canAccess(bean)) { + method.setAccessible(true); + } + method.invoke(bean, convert(node, method.getGenericParameterTypes()[0])); + return; + } + } + for (Class<?> type = bean.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(bean, convert(node, field.getGenericType())); + return; + } catch (NoSuchFieldException ignored) { + // Continue through the class hierarchy. + } + } + throw new NoSuchFieldException(name); + } + + private Object convert(XmlNode node, Type target) throws Exception { + Class<?> type = rawType(target); + if (type == XmlNode.class) { + return node; + } + Object scalar = convertScalar(node, type); + if (scalar != NOT_SCALAR) { + return scalar; + } + if (type.isArray()) { + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + String[] parts = text != null ? text.split(",", -1) : new String[0]; + Object array = Array.newInstance(type.getComponentType(), parts.length); + for (int i = 0; i < parts.length; i++) { + Array.set(array, i, convert(XmlNode.newInstance("item", parts[i].trim()), type.getComponentType())); + } + return array; + } + Object array = Array.newInstance(type.getComponentType(), children.size()); + for (int i = 0; i < children.size(); i++) { + Array.set(array, i, convert(children.get(i), type.getComponentType())); + } + return array; + } + if (Collection.class.isAssignableFrom(type)) { + Type itemType = target instanceof ParameterizedType p ? p.getActualTypeArguments()[0] : Object.class; + Collection<Object> values = + Set.class.isAssignableFrom(type) ? new java.util.LinkedHashSet<>() : new ArrayList<>(); + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + if (text != null) { + for (String part : text.split(",", -1)) { + values.add(convert(XmlNode.newInstance("item", part.trim()), itemType)); + } + } + } else { + for (XmlNode child : children) { + values.add(convert(child, itemType)); + } + } + return values; + } + if (Map.class.isAssignableFrom(type)) { + Type valueType = target instanceof ParameterizedType p ? p.getActualTypeArguments()[1] : Object.class; + if (Properties.class.isAssignableFrom(type)) { + Properties properties = new Properties(); + for (XmlNode child : node.children()) { + // Support Plexus-style: <property><name>k</name><value>v</value></property> + XmlNode nameNode = child.child("name"); + XmlNode valueNode = child.child("value"); + if (nameNode != null && valueNode != null) { + String key = evaluateNodeAsString(nameNode); + Object val = convert(valueNode, valueType); + if (key != null && val != null) { + properties.put(key, val); + } + } else { + // Support key=element-name, value=text: <key>value</key> + Object val = convert(child, valueType); + if (val != null) { + properties.put(child.name(), val); + } + } Review Comment: ⚠️ **`<property>` with `<name>` but no `<value>` falls into the wrong `else`-branch.** The `else` condition fires when `nameNode != null && valueNode == null` — it uses `child.name()` (the literal string `"property"`) as the key instead of the `<name>` element's content. The `<name>` text is discarded. The correct fix is to only run the simple `<key>text</key>` path when `nameNode == null` (i.e., not Plexus-style at all): ```suggestion if (nameNode != null && valueNode != null) { String key = evaluateNodeAsString(nameNode); Object val = convert(valueNode, valueType); if (key != null && val != null) { properties.put(key, val); } } else if (nameNode == null) { // Support key=element-name, value=text: <key>value</key> Object val = convert(child, valueType); if (val != null) { properties.put(child.name(), val); } } // nameNode != null && valueNode == null: malformed Plexus-style element, skip silently ``` ########## impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java: ########## @@ -0,0 +1,415 @@ +/* + * 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.plugin.internal; + +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.plugin.PluginConfigurationException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; + +/** Configures a Maven 4 mojo directly from the public Maven XML model. */ +public final class V4MojoConfigurator { + private static final Object NOT_SCALAR = new Object(); + + private final MojoExpressionEvaluator evaluator; + private final ClassLoader classLoader; + private final PluginDescriptor pluginDescriptor; + + public V4MojoConfigurator( + MojoExpressionEvaluator evaluator, ClassLoader classLoader, PluginDescriptor pluginDescriptor) { + this.evaluator = evaluator; + this.classLoader = classLoader; + this.pluginDescriptor = pluginDescriptor; + } + + public void configure(Object mojo, XmlNode configuration) throws PluginConfigurationException { + if (configuration == null) { + return; + } + for (XmlNode child : configuration.children()) { + try { + set(mojo, child.name(), child); + } catch (Exception e) { + throw new PluginConfigurationException( + pluginDescriptor, + "Unable to configure '" + child.name() + "' on " + + mojo.getClass().getName(), + e); + } + } + } + + private void set(Object bean, String name, XmlNode node) throws Exception { + if (name.isEmpty()) { + throw new NoSuchFieldException("<empty element name>"); + } + String title = Character.toUpperCase(name.charAt(0)) + name.substring(1); + for (Method method : bean.getClass().getMethods()) { + if ((method.getName().equals("set" + title) || method.getName().equals("add" + title)) + && method.getParameterCount() == 1) { + if (!method.canAccess(bean)) { + method.setAccessible(true); + } + method.invoke(bean, convert(node, method.getGenericParameterTypes()[0])); + return; + } + } + for (Class<?> type = bean.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(bean, convert(node, field.getGenericType())); + return; + } catch (NoSuchFieldException ignored) { + // Continue through the class hierarchy. + } + } + throw new NoSuchFieldException(name); + } + + private Object convert(XmlNode node, Type target) throws Exception { + Class<?> type = rawType(target); + if (type == XmlNode.class) { + return node; + } + Object scalar = convertScalar(node, type); + if (scalar != NOT_SCALAR) { + return scalar; + } + if (type.isArray()) { + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + String[] parts = text != null ? text.split(",", -1) : new String[0]; + Object array = Array.newInstance(type.getComponentType(), parts.length); + for (int i = 0; i < parts.length; i++) { + Array.set(array, i, convert(XmlNode.newInstance("item", parts[i].trim()), type.getComponentType())); + } + return array; + } + Object array = Array.newInstance(type.getComponentType(), children.size()); + for (int i = 0; i < children.size(); i++) { + Array.set(array, i, convert(children.get(i), type.getComponentType())); + } + return array; + } + if (Collection.class.isAssignableFrom(type)) { + Type itemType = target instanceof ParameterizedType p ? p.getActualTypeArguments()[0] : Object.class; + Collection<Object> values = + Set.class.isAssignableFrom(type) ? new java.util.LinkedHashSet<>() : new ArrayList<>(); + List<XmlNode> children = node.children(); + if (children.isEmpty()) { + // No child elements: treat the text value as a comma-separated list + String text = evaluateNodeAsString(node); + if (text != null) { + for (String part : text.split(",", -1)) { + values.add(convert(XmlNode.newInstance("item", part.trim()), itemType)); Review Comment: ⚠️ **Same `split(",", -1)` empty-token issue as the array path above.** ```suggestion for (String part : text.split(",")) { String trimmed = part.trim(); if (!trimmed.isEmpty()) { values.add(convert(XmlNode.newInstance("item", trimmed), itemType)); } } ``` ########## impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusMojoExpressionEvaluatorAdapter.java: ########## @@ -0,0 +1,50 @@ +/* + * 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.plugin.internal; + +import java.io.File; + +import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator; +import org.codehaus.plexus.component.configurator.expression.TypeAwareExpressionEvaluator; + +/** + * Adapts a Plexus {@link ExpressionEvaluator} to the internal {@link MojoExpressionEvaluator} interface, + * isolating {@link V4MojoConfigurator} from the Plexus API. + */ +class PlexusMojoExpressionEvaluatorAdapter implements MojoExpressionEvaluator { + private final TypeAwareExpressionEvaluator delegate; + + PlexusMojoExpressionEvaluatorAdapter(ExpressionEvaluator evaluator) { + this.delegate = (TypeAwareExpressionEvaluator) evaluator; + } Review Comment: ⚠️ **Unchecked cast to `TypeAwareExpressionEvaluator` — deferred `ClassCastException`.** The constructor accepts `ExpressionEvaluator` (the base interface) but immediately casts it to `TypeAwareExpressionEvaluator`. If a non-`TypeAware` evaluator is ever passed, the cast fails silently at construction time and throws `ClassCastException` only when `evaluate()` is called — which is harder to diagnose. Since `V4MojoConfiguratorTestHelper` already takes `TypeAwareExpressionEvaluator` directly, the simplest fix is to narrow the parameter type, eliminating the cast entirely: ```suggestion PlexusMojoExpressionEvaluatorAdapter(TypeAwareExpressionEvaluator evaluator) { this.delegate = evaluator; } ``` `DefaultMavenPluginManager` will need to cast at the call site (or narrow its local variable), but the cast failure will then be explicit and correctly attributed. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
