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

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

commit 00a83b6a944ea05db889a6d5cc9f565856fed152
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.
    
    Add a `Project.getOutputDirectory(ProjectScope)` for avoiding the need to 
repeat the same code in the plugins.
---
 .../main/java/org/apache/maven/api/Project.java    | 30 ++++++++
 .../main/java/org/apache/maven/api/SourceRoot.java | 23 ++++++
 .../java/org/apache/maven/api/SourceRootTest.java  | 89 ++++++++++++++++++++++
 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 +++++++++++++++-
 7 files changed, 237 insertions(+), 10 deletions(-)

diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Project.java 
b/api/maven-api-core/src/main/java/org/apache/maven/api/Project.java
index 8e989ad4ae..2fb4f4f7ba 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Project.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Project.java
@@ -24,6 +24,7 @@
 
 import org.apache.maven.api.annotations.Experimental;
 import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
 import org.apache.maven.api.model.Build;
 import org.apache.maven.api.model.Model;
 import org.apache.maven.api.model.Profile;
@@ -172,6 +173,35 @@ default Build getBuild() {
     @Nonnull
     Path getBasedir();
 
+    /**
+     * Returns the directory where files generated by the build are placed.
+     * The directory depends on the scope:
+     *
+     * <ul>
+     *   <li>If {@link ProjectScope#MAIN}, returns the directory where 
compiled application classes are placed.</li>
+     *   <li>If {@link ProjectScope#TEST}, returns the directory where 
compiled test classes are placed.</li>
+     *   <li>Otherwise (including {@code null}), returns the parent directory 
where all generated files are placed.</li>
+     * </ul>
+     *
+     * @param scope the scope of the generated files for which to get the 
directory, or {@code null} for all
+     * @return the output directory of files that are generated for the given 
scope
+     *
+     * @see SourceRoot#targetPath(Project)
+     */
+    @Nonnull
+    default Path getOutputDirectory(@Nullable ProjectScope scope) {
+        String dir;
+        Build build = getBuild();
+        if (scope == ProjectScope.MAIN) {
+            dir = build.getOutputDirectory();
+        } else if (scope == ProjectScope.TEST) {
+            dir = build.getTestOutputDirectory();
+        } else {
+            dir = build.getDirectory();
+        }
+        return getBasedir().resolve(dir);
+    }
+
     /**
      * {@return the project direct dependencies (directly specified or 
inherited)}.
      */
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 0abd9bbe6d..dc08a6f24c 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,8 @@
 import java.util.List;
 import java.util.Optional;
 
+import org.apache.maven.api.annotations.Nonnull;
+
 /**
  * A root directory of source files.
  * The sources may be Java main classes, test classes, resources or anything 
else identified by the scope.
@@ -152,6 +154,27 @@ default Optional<Path> targetPath() {
         return Optional.empty();
     }
 
+    /**
+     * {@return the explicit target path resolved against the default target 
path}
+     * Invoking this method is equivalent to getting the default output 
directory
+     * by a call to {@code project.getOutputDirectory(scope())}, then 
resolving the
+     * {@linkplain #targetPath() target path} (if present) against that 
default directory.
+     * Note that if the target path is absolute, the result is that target 
path unchanged.
+     *
+     * @param project the project to use for getting default directories
+     *
+     * @see Project#getOutputDirectory(ProjectScope)
+     */
+    @Nonnull
+    default Path targetPath(@Nonnull Project project) {
+        Optional<Path> targetPath = targetPath();
+        // The test for `isAbsolute()` is a small optimization for avoiding 
the call to `getOutputDirectory(…)`.
+        return targetPath.filter(Path::isAbsolute).orElseGet(() -> {
+            Path base = project.getOutputDirectory(scope());
+            return targetPath.map(base::resolve).orElse(base);
+        });
+    }
+
     /**
      * {@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..a316550aee
--- /dev/null
+++ b/api/maven-api-core/src/test/java/org/apache/maven/api/SourceRootTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.any;
+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"));
+        
when(project.getOutputDirectory(any(ProjectScope.class))).thenCallRealMethod();
+
+        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 6222040050..653b355f3f 100644
--- a/api/maven-api-model/src/main/mdo/maven.mdo
+++ b/api/maven-api-model/src/main/mdo/maven.mdo
@@ -2129,8 +2129,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 405da93e4a..1b1976c7f6 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;
@@ -644,11 +645,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