This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git

commit e69bff99d8efb869b29dfe888302039d01f8333d
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sat Oct 25 17:08:29 2025 +0200

    When the value of `<targetPath>` is a relative directory, the specification 
in `maven.mdo`
    requires that we resolve against `${project.build.outputDirectory}`, which 
is not `baseDir`.
    Also modify the specification for resolving against 
`${project.build.testOutputDirectory}`
    if the scope is test and `${project.build.directory}` is the scope is 
neither main or test.
---
 .../main/java/org/apache/maven/api/SourceRoot.java | 35 +++++++++
 .../java/org/apache/maven/api/SourceRootTest.java  | 87 ++++++++++++++++++++++
 api/maven-api-model/src/main/mdo/maven.mdo         | 12 ++-
 .../maven/project/DefaultProjectBuilder.java       | 12 ++-
 .../org/apache/maven/impl/DefaultSourceRoot.java   | 16 ++--
 .../apache/maven/impl/DefaultSourceRootTest.java   | 65 +++++++++++++++-
 6 files changed, 217 insertions(+), 10 deletions(-)

diff --git 
a/api/maven-api-core/src/main/java/org/apache/maven/api/SourceRoot.java 
b/api/maven-api-core/src/main/java/org/apache/maven/api/SourceRoot.java
index 6904b76d84..c8b4d3b771 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/SourceRoot.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/SourceRoot.java
@@ -24,6 +24,9 @@
 import java.util.List;
 import java.util.Optional;
 
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.model.Build;
+
 /**
  * A root directory of source files.
  * The sources may be Java main classes, test classes, resources or anything 
else identified by the scope.
@@ -151,6 +154,38 @@ default Optional<Path> targetPath() {
         return Optional.empty();
     }
 
+    /**
+     * {@return the explicit target path resolved against the default target 
path}
+     * If the {@linkplain #targetPath() explicit target path} is present and 
absolute, then it is returned as-is.
+     * If absent, then the one of the following value is returned by default:
+     *
+     * <ul>
+     *   <li>{@link Build#getOutputDirectory()} if the scope is {@link 
ProjectScope#MAIN},</li>
+     *   <li>{@link Build#getTestOutputDirectory()} if the scope is {@link 
ProjectScope#TEST},</li>
+     *   <li>{@link Build#getDirectory()} otherwise.</li>
+     * </ul>
+     *
+     * If the {@linkplain #targetPath() explicit target path} is present but 
relative,
+     * then it is resolved against the above-cited default directory.
+     *
+     * @param project the project to use for getting default directories
+     */
+    @Nonnull
+    default Path targetPath(@Nonnull Project project) {
+        Build build = project.getBuild();
+        ProjectScope scope = scope();
+        String base;
+        if (scope == ProjectScope.MAIN) {
+            base = build.getOutputDirectory();
+        } else if (scope == ProjectScope.TEST) {
+            base = build.getTestOutputDirectory();
+        } else {
+            base = build.getDirectory();
+        }
+        Path dir = project.getBasedir().resolve(base);
+        return targetPath().map(dir::resolve).orElse(dir);
+    }
+
     /**
      * {@return whether resources are filtered to replace tokens with 
parameterized values}
      * The default value is {@code false}.
diff --git 
a/api/maven-api-core/src/test/java/org/apache/maven/api/SourceRootTest.java 
b/api/maven-api-core/src/test/java/org/apache/maven/api/SourceRootTest.java
new file mode 100644
index 0000000000..9f65f5d0af
--- /dev/null
+++ b/api/maven-api-core/src/test/java/org/apache/maven/api/SourceRootTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.api;
+
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.util.Collection;
+import java.util.Optional;
+
+import org.apache.maven.api.model.Build;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class SourceRootTest implements SourceRoot {
+    private ProjectScope scope;
+
+    private Language language;
+
+    private String moduleName;
+
+    @Override
+    public ProjectScope scope() {
+        return (scope != null) ? scope : SourceRoot.super.scope();
+    }
+
+    @Override
+    public Language language() {
+        return (language != null) ? language : SourceRoot.super.language();
+    }
+
+    @Override
+    public Optional<String> module() {
+        return Optional.ofNullable(moduleName);
+    }
+
+    @Override
+    public PathMatcher matcher(Collection<String> defaultIncludes, boolean 
useDefaultExcludes) {
+        return null; // Not used for this test.
+    }
+
+    @Test
+    void testDirectory() {
+        assertEquals(Path.of("src", "main", "java"), directory());
+
+        scope = ProjectScope.TEST;
+        assertEquals(Path.of("src", "test", "java"), directory());
+
+        moduleName = "org.foo";
+        assertEquals(Path.of("src", "org.foo", "test", "java"), directory());
+    }
+
+    @Test
+    void testTargetPath() {
+        Build build = mock(Build.class);
+        when(build.getDirectory()).thenReturn("target");
+        when(build.getOutputDirectory()).thenReturn("target/classes");
+        when(build.getTestOutputDirectory()).thenReturn("target/test-classes");
+
+        Project project = mock(Project.class);
+        when(project.getBuild()).thenReturn(build);
+        when(project.getBasedir()).thenReturn(Path.of("myproject"));
+
+        assertEquals(Path.of("myproject", "target", "classes"), 
targetPath(project));
+
+        scope = ProjectScope.TEST;
+        assertEquals(Path.of("myproject", "target", "test-classes"), 
targetPath(project));
+    }
+}
diff --git a/api/maven-api-model/src/main/mdo/maven.mdo 
b/api/maven-api-model/src/main/mdo/maven.mdo
index 83c3c06534..3a18900ac3 100644
--- a/api/maven-api-model/src/main/mdo/maven.mdo
+++ b/api/maven-api-model/src/main/mdo/maven.mdo
@@ -2160,8 +2160,16 @@
           <description>
             <![CDATA[
             Specifies an explicit target path, overriding the default value.
-            The path is relative to the {@code 
${project.build.outputDirectory}} directory,
-            which is typically {@code target/classes} in a Java project.
+            If unspecified, then the default value is one of the following:
+
+            <ul>
+              <li>{@code ${project.build.outputDirectory}} (typically {@code 
target/classes}) if {@code scope} is "main",</li>
+              <li>{@code ${project.build.testOutputDirectory}} (typically 
{@code target/test-classes}) if {@code scope} is "test",</li>
+              <li>{@code ${project.build.directory}} (typically {@code 
target}) otherwise.</li>
+            </ul>
+
+            <p>If this property is specified but is a relative path,
+            then the path is resolved against the above-cited default 
value.</p>
 
             <p>When a target path is explicitly specified, the values of the 
{@code module} and {@code targetVersion}
             elements are not used for inferring the path (they are still used 
as compiler options however).
diff --git 
a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java
 
b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java
index f18d590b61..245d85117d 100644
--- 
a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java
+++ 
b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java
@@ -40,6 +40,7 @@
 import java.util.Properties;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -650,11 +651,20 @@ private void initProject(MavenProject project, 
ModelBuilderResult result) {
                 Build build = project.getBuild().getDelegate();
                 List<org.apache.maven.api.model.Source> sources = 
build.getSources();
                 Path baseDir = project.getBaseDirectory();
+                Function<ProjectScope, String> outputDirectory = (scope) -> {
+                    if (scope == ProjectScope.MAIN) {
+                        return build.getOutputDirectory();
+                    } else if (scope == ProjectScope.TEST) {
+                        return build.getTestOutputDirectory();
+                    } else {
+                        return build.getDirectory();
+                    }
+                };
                 boolean hasScript = false;
                 boolean hasMain = false;
                 boolean hasTest = false;
                 for (var source : sources) {
-                    var src = DefaultSourceRoot.fromModel(session, baseDir, 
source);
+                    var src = DefaultSourceRoot.fromModel(session, baseDir, 
outputDirectory, source);
                     project.addSourceRoot(src);
                     Language language = src.language();
                     if (Language.JAVA_FAMILY.equals(language)) {
diff --git 
a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java 
b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java
index 57994c0fe1..6ffec2ce88 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java
@@ -24,6 +24,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.function.Function;
 
 import org.apache.maven.api.Language;
 import org.apache.maven.api.ProjectScope;
@@ -114,11 +115,13 @@ public DefaultSourceRoot(
     /**
      * Creates a new instance from the given model.
      *
-     * @param session the session of resolving extensible enumerations
-     * @param baseDir the base directory for resolving relative paths
-     * @param source a source element from the model
+     * @param session    the session of resolving extensible enumerations
+     * @param baseDir    the base directory for resolving relative paths
+     * @param outputDir  supplier of output directory relative to {@code 
baseDir}
+     * @param source     a source element from the model
      */
-    public static DefaultSourceRoot fromModel(final Session session, final 
Path baseDir, final Source source) {
+    public static DefaultSourceRoot fromModel(
+            Session session, Path baseDir, Function<ProjectScope, String> 
outputDir, Source source) {
         ProjectScope scope =
                 
nonBlank(source.getScope()).map(session::requireProjectScope).orElse(ProjectScope.MAIN);
         Language language =
@@ -139,7 +142,10 @@ public static DefaultSourceRoot fromModel(final Session 
session, final Path base
                 source.getIncludes(),
                 source.getExcludes(),
                 source.isStringFiltering(),
-                
nonBlank(source.getTargetPath()).map(baseDir::resolve).orElse(null),
+                nonBlank(source.getTargetPath())
+                        .map((targetPath) ->
+                                
baseDir.resolve(outputDir.apply(scope)).resolve(targetPath))
+                        .orElse(null),
                 source.isEnabled());
     }
 
diff --git 
a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSourceRootTest.java
 
b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSourceRootTest.java
index 446a0315e9..6ceedfea56 100644
--- 
a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSourceRootTest.java
+++ 
b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSourceRootTest.java
@@ -21,6 +21,7 @@
 import java.nio.file.Path;
 import java.util.List;
 import java.util.Optional;
+import java.util.function.Function;
 
 import org.apache.maven.api.Language;
 import org.apache.maven.api.ProjectScope;
@@ -56,10 +57,28 @@ public void setup() {
         
stub.when(session.requireLanguage(eq("resources"))).thenReturn(Language.RESOURCES);
     }
 
+    /**
+     * Returns the output directory relative to the base directory.
+     */
+    private static Function<ProjectScope, String> outputDirectory() {
+        return (scope) -> {
+            if (scope == ProjectScope.MAIN) {
+                return "target/classes";
+            } else if (scope == ProjectScope.TEST) {
+                return "target/test-classes";
+            } else {
+                return "target";
+            }
+        };
+    }
+
     @Test
     void testMainJavaDirectory() {
         var source = DefaultSourceRoot.fromModel(
-                session, Path.of("myproject"), Source.newBuilder().build());
+                session,
+                Path.of("myproject"),
+                outputDirectory(),
+                Source.newBuilder().build());
 
         assertTrue(source.module().isEmpty());
         assertEquals(ProjectScope.MAIN, source.scope());
@@ -71,7 +90,10 @@ void testMainJavaDirectory() {
     @Test
     void testTestJavaDirectory() {
         var source = DefaultSourceRoot.fromModel(
-                session, Path.of("myproject"), 
Source.newBuilder().scope("test").build());
+                session,
+                Path.of("myproject"),
+                outputDirectory(),
+                Source.newBuilder().scope("test").build());
 
         assertTrue(source.module().isEmpty());
         assertEquals(ProjectScope.TEST, source.scope());
@@ -85,6 +107,7 @@ void testTestResourceDirectory() {
         var source = DefaultSourceRoot.fromModel(
                 session,
                 Path.of("myproject"),
+                outputDirectory(),
                 Source.newBuilder().scope("test").lang("resources").build());
 
         assertTrue(source.module().isEmpty());
@@ -99,6 +122,7 @@ void testModuleMainDirectory() {
         var source = DefaultSourceRoot.fromModel(
                 session,
                 Path.of("myproject"),
+                outputDirectory(),
                 Source.newBuilder().module("org.foo.bar").build());
 
         assertEquals("org.foo.bar", source.module().orElseThrow());
@@ -113,6 +137,7 @@ void testModuleTestDirectory() {
         var source = DefaultSourceRoot.fromModel(
                 session,
                 Path.of("myproject"),
+                outputDirectory(),
                 
Source.newBuilder().module("org.foo.bar").scope("test").build());
 
         assertEquals("org.foo.bar", source.module().orElseThrow());
@@ -122,6 +147,42 @@ void testModuleTestDirectory() {
         assertTrue(source.targetVersion().isEmpty());
     }
 
+    /**
+     * Tests that relative target paths are resolved against the right base 
directory.
+     */
+    @Test
+    void testRelativeMainTargetPath() {
+        var source = DefaultSourceRoot.fromModel(
+                session,
+                Path.of("myproject"),
+                outputDirectory(),
+                Source.newBuilder().targetPath("user-output").build());
+
+        assertEquals(ProjectScope.MAIN, source.scope());
+        assertEquals(Language.JAVA_FAMILY, source.language());
+        assertEquals(
+                Path.of("myproject", "target", "classes", "user-output"),
+                source.targetPath().orElseThrow());
+    }
+
+    /**
+     * Tests that relative target paths are resolved against the right base 
directory.
+     */
+    @Test
+    void testRelativeTestTargetPath() {
+        var source = DefaultSourceRoot.fromModel(
+                session,
+                Path.of("myproject"),
+                outputDirectory(),
+                
Source.newBuilder().targetPath("user-output").scope("test").build());
+
+        assertEquals(ProjectScope.TEST, source.scope());
+        assertEquals(Language.JAVA_FAMILY, source.language());
+        assertEquals(
+                Path.of("myproject", "target", "test-classes", "user-output"),
+                source.targetPath().orElseThrow());
+    }
+
     /*MNG-11062*/
     @Test
     void testExtractsTargetPathFromResource() {

Reply via email to