Skip to content

Commit 9b95526

Browse files
authored
Fix resource targetPath resolution to be relative to output directory (fixes #11381) (#11394)
This commit fixes the regression where resources with a relative targetPath were being copied to the project root instead of relative to the output directory (target/classes or target/test-classes). Changes: 1. DefaultSourceRoot.fromModel: Store targetPath as a relative path instead of resolving it against baseDir and outputDir. This ensures that SourceRoot.targetPath() returns a relative path as intended by the Maven 4 API javadoc. 2. ConnectedResource.computeRelativeTargetPath: Simplified to directly return the relative targetPath from SourceRoot, since it's now always stored as relative. 3. Updated tests to expect relative paths from SourceRoot.targetPath(). Maven 4 API Conformance: - SourceRoot.targetPath() returns an Optional<Path> containing the explicit target path, which should be relative to the output directory (or absolute if explicitly specified as absolute). - SourceRoot.targetPath(Project) resolves this relative path against the project's output directory to produce an absolute path. Maven 3 Compatibility: - Resource.getTargetPath() in Maven 3 was always relative to the output directory. This behavior is preserved by storing targetPath as relative in SourceRoot and converting it back to relative for the Resource API via ConnectedResource. Example: With <targetPath>custom-dir</targetPath>: - Maven 3: Resources copied to target/classes/custom-dir - Maven 4 (before fix): Resources copied to project-root/custom-dir - Maven 4 (after fix): Resources copied to target/classes/custom-dir Fixes #11381
1 parent 83a3863 commit 9b95526

File tree

10 files changed

+391
-36
lines changed

10 files changed

+391
-36
lines changed

api/maven-api-core/src/main/java/org/apache/maven/api/Project.java

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,109 @@ default Build getBuild() {
174174
Path getBasedir();
175175

176176
/**
177-
* Returns the directory where files generated by the build are placed.
178-
* The directory depends on the scope:
179-
*
177+
* {@return the absolute path to the directory where files generated by the build are placed}
178+
* <p>
179+
* <strong>Purpose:</strong> This method provides the base output directory for a given scope,
180+
* which serves as the destination for compiled classes, processed resources, and other generated files.
181+
* The returned path is always absolute.
182+
* </p>
183+
* <p>
184+
* <strong>Scope-based Directory Resolution:</strong>
185+
* </p>
186+
* <table class="striped">
187+
* <caption>Output Directory by Scope</caption>
188+
* <thead>
189+
* <tr>
190+
* <th>Scope Parameter</th>
191+
* <th>Build Configuration</th>
192+
* <th>Typical Path</th>
193+
* <th>Contents</th>
194+
* </tr>
195+
* </thead>
196+
* <tbody>
197+
* <tr>
198+
* <td>{@link ProjectScope#MAIN}</td>
199+
* <td>{@code build.getOutputDirectory()}</td>
200+
* <td>{@code target/classes}</td>
201+
* <td>Compiled application classes and processed main resources</td>
202+
* </tr>
203+
* <tr>
204+
* <td>{@link ProjectScope#TEST}</td>
205+
* <td>{@code build.getTestOutputDirectory()}</td>
206+
* <td>{@code target/test-classes}</td>
207+
* <td>Compiled test classes and processed test resources</td>
208+
* </tr>
209+
* <tr>
210+
* <td>{@code null} or other</td>
211+
* <td>{@code build.getDirectory()}</td>
212+
* <td>{@code target}</td>
213+
* <td>Parent directory for all build outputs</td>
214+
* </tr>
215+
* </tbody>
216+
* </table>
217+
* <p>
218+
* <strong>Role in {@link SourceRoot} Path Resolution:</strong>
219+
* </p>
220+
* <p>
221+
* This method is the foundation for {@link SourceRoot#targetPath(Project)} path resolution.
222+
* When a {@link SourceRoot} has a relative {@code targetPath}, it is resolved against the
223+
* output directory returned by this method for the source root's scope. This ensures that:
224+
* </p>
180225
* <ul>
181-
* <li>If {@link ProjectScope#MAIN}, returns the directory where compiled application classes are placed.</li>
182-
* <li>If {@link ProjectScope#TEST}, returns the directory where compiled test classes are placed.</li>
183-
* <li>Otherwise (including {@code null}), returns the parent directory where all generated files are placed.</li>
226+
* <li>Main resources with {@code targetPath="META-INF"} are copied to {@code target/classes/META-INF}</li>
227+
* <li>Test resources with {@code targetPath="test-data"} are copied to {@code target/test-classes/test-data}</li>
228+
* <li>Resources without an explicit {@code targetPath} are copied to the root of the output directory</li>
184229
* </ul>
230+
* <p>
231+
* <strong>Maven 3 Compatibility:</strong>
232+
* </p>
233+
* <p>
234+
* This behavior maintains the Maven 3.x semantic where resource {@code targetPath} elements
235+
* are resolved relative to the appropriate output directory ({@code project.build.outputDirectory}
236+
* or {@code project.build.testOutputDirectory}), <strong>not</strong> the project base directory.
237+
* </p>
238+
* <p>
239+
* In Maven 3, when a resource configuration specifies:
240+
* </p>
241+
* <pre>{@code
242+
* <resource>
243+
* <directory>src/main/resources</directory>
244+
* <targetPath>META-INF/resources</targetPath>
245+
* </resource>
246+
* }</pre>
247+
* <p>
248+
* The maven-resources-plugin resolves {@code targetPath} as:
249+
* {@code project.build.outputDirectory + "/" + targetPath}, which results in
250+
* {@code target/classes/META-INF/resources}. This method provides the same base directory
251+
* ({@code target/classes}) for Maven 4 API consumers.
252+
* </p>
253+
* <p>
254+
* <strong>Example:</strong>
255+
* </p>
256+
* <pre>{@code
257+
* Project project = ...; // project at /home/user/myproject
258+
*
259+
* // Get main output directory
260+
* Path mainOutput = project.getOutputDirectory(ProjectScope.MAIN);
261+
* // Result: /home/user/myproject/target/classes
262+
*
263+
* // Get test output directory
264+
* Path testOutput = project.getOutputDirectory(ProjectScope.TEST);
265+
* // Result: /home/user/myproject/target/test-classes
266+
*
267+
* // Get build directory
268+
* Path buildDir = project.getOutputDirectory(null);
269+
* // Result: /home/user/myproject/target
270+
* }</pre>
185271
*
186-
* @param scope the scope of the generated files for which to get the directory, or {@code null} for all
187-
* @return the output directory of files that are generated for the given scope
272+
* @param scope the scope of the generated files for which to get the directory, or {@code null} for the build directory
273+
* @return the absolute path to the output directory for the given scope
188274
*
189275
* @see SourceRoot#targetPath(Project)
276+
* @see SourceRoot#targetPath()
277+
* @see Build#getOutputDirectory()
278+
* @see Build#getTestOutputDirectory()
279+
* @see Build#getDirectory()
190280
*/
191281
@Nonnull
192282
default Path getOutputDirectory(@Nullable ProjectScope scope) {

api/maven-api-core/src/main/java/org/apache/maven/api/SourceRoot.java

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,24 +144,159 @@ default Optional<Version> targetVersion() {
144144

145145
/**
146146
* {@return an explicit target path, overriding the default value}
147-
* When a target path is explicitly specified, the values of the {@link #module()} and {@link #targetVersion()}
148-
* elements are not used for inferring the path (they are still used as compiler options however).
149-
* It means that for scripts and resources, the files below the path specified by {@link #directory()}
147+
* <p>
148+
* <strong>Important:</strong> This method returns the target path <em>as specified in the configuration</em>,
149+
* which may be relative or absolute. It does <strong>not</strong> perform any path resolution.
150+
* For the fully resolved absolute path, use {@link #targetPath(Project)} instead.
151+
* </p>
152+
* <p>
153+
* <strong>Return Value Semantics:</strong>
154+
* </p>
155+
* <ul>
156+
* <li><strong>Empty Optional</strong> - No explicit target path was specified. Files should be copied
157+
* to the root of the output directory (see {@link Project#getOutputDirectory(ProjectScope)}).</li>
158+
* <li><strong>Relative Path</strong> (e.g., {@code Path.of("META-INF/resources")}) - The path is
159+
* <em>intended to be resolved</em> relative to the output directory for this source root's {@link #scope()}.
160+
* <ul>
161+
* <li>For {@link ProjectScope#MAIN}: relative to {@code target/classes}</li>
162+
* <li>For {@link ProjectScope#TEST}: relative to {@code target/test-classes}</li>
163+
* </ul>
164+
* The actual resolution is performed by {@link #targetPath(Project)}.</li>
165+
* <li><strong>Absolute Path</strong> (e.g., {@code Path.of("/tmp/custom")}) - The path is used as-is
166+
* without any resolution. Files will be copied to this exact location.</li>
167+
* </ul>
168+
* <p>
169+
* <strong>Maven 3 Compatibility:</strong> This behavior maintains compatibility with Maven 3.x,
170+
* where resource {@code targetPath} elements were always interpreted as relative to the output directory
171+
* ({@code project.build.outputDirectory} or {@code project.build.testOutputDirectory}),
172+
* not the project base directory. Maven 3 plugins (like maven-resources-plugin) expect to receive
173+
* the relative path and perform the resolution themselves.
174+
* </p>
175+
* <p>
176+
* <strong>Effect on Module and Target Version:</strong>
177+
* When a target path is explicitly specified, the values of {@link #module()} and {@link #targetVersion()}
178+
* are not used for inferring the output path (they are still used as compiler options however).
179+
* This means that for scripts and resources, the files below the path specified by {@link #directory()}
150180
* are copied to the path specified by {@code targetPath()} with the exact same directory structure.
181+
* </p>
182+
* <p>
183+
* <strong>Usage Guidance:</strong>
184+
* </p>
185+
* <ul>
186+
* <li><strong>For Maven 4 API consumers:</strong> Use {@link #targetPath(Project)} to get the
187+
* fully resolved absolute path where files should be copied.</li>
188+
* <li><strong>For Maven 3 compatibility layer:</strong> Use this method to get the path as specified
189+
* in the configuration, which can then be passed to legacy plugins that expect to perform
190+
* their own resolution.</li>
191+
* <li><strong>For implementers:</strong> Store the path exactly as provided in the configuration.
192+
* Do not resolve relative paths at storage time.</li>
193+
* </ul>
194+
*
195+
* @see #targetPath(Project)
196+
* @see Project#getOutputDirectory(ProjectScope)
151197
*/
152198
default Optional<Path> targetPath() {
153199
return Optional.empty();
154200
}
155201

156202
/**
157-
* {@return the explicit target path resolved against the default target path}
158-
* Invoking this method is equivalent to getting the default output directory
159-
* by a call to {@code project.getOutputDirectory(scope())}, then resolving the
160-
* {@linkplain #targetPath() target path} (if present) against that default directory.
161-
* Note that if the target path is absolute, the result is that target path unchanged.
203+
* {@return the fully resolved absolute target path where files should be copied}
204+
* <p>
205+
* <strong>Purpose:</strong> This method performs the complete path resolution logic, converting
206+
* the potentially relative {@link #targetPath()} into an absolute filesystem path. This is the
207+
* method that Maven 4 API consumers should use when they need to know the actual destination
208+
* directory for copying files.
209+
* </p>
210+
* <p>
211+
* <strong>Resolution Algorithm:</strong>
212+
* </p>
213+
* <ol>
214+
* <li>Obtain the {@linkplain #targetPath() configured target path} (which may be empty, relative, or absolute)</li>
215+
* <li>If the configured target path is absolute (e.g., {@code /tmp/custom}):
216+
* <ul><li>Return it unchanged (no resolution needed)</li></ul></li>
217+
* <li>Otherwise, get the output directory for this source root's {@link #scope()} by calling
218+
* {@code project.getOutputDirectory(scope())}:
219+
* <ul>
220+
* <li>For {@link ProjectScope#MAIN}: typically {@code /path/to/project/target/classes}</li>
221+
* <li>For {@link ProjectScope#TEST}: typically {@code /path/to/project/target/test-classes}</li>
222+
* </ul></li>
223+
* <li>If the configured target path is empty:
224+
* <ul><li>Return the output directory as-is</li></ul></li>
225+
* <li>If the configured target path is relative (e.g., {@code META-INF/resources}):
226+
* <ul><li>Resolve it against the output directory using {@code outputDirectory.resolve(targetPath)}</li></ul></li>
227+
* </ol>
228+
* <p>
229+
* <strong>Concrete Examples:</strong>
230+
* </p>
231+
* <p>
232+
* Given a project at {@code /home/user/myproject} with {@link ProjectScope#MAIN}:
233+
* </p>
234+
* <table class="striped">
235+
* <caption>Target Path Resolution Examples</caption>
236+
* <thead>
237+
* <tr>
238+
* <th>Configuration ({@code targetPath()})</th>
239+
* <th>Output Directory</th>
240+
* <th>Result ({@code targetPath(project)})</th>
241+
* <th>Explanation</th>
242+
* </tr>
243+
* </thead>
244+
* <tbody>
245+
* <tr>
246+
* <td>{@code Optional.empty()}</td>
247+
* <td>{@code /home/user/myproject/target/classes}</td>
248+
* <td>{@code /home/user/myproject/target/classes}</td>
249+
* <td>No explicit path → use output directory</td>
250+
* </tr>
251+
* <tr>
252+
* <td>{@code Optional.of(Path.of("META-INF"))}</td>
253+
* <td>{@code /home/user/myproject/target/classes}</td>
254+
* <td>{@code /home/user/myproject/target/classes/META-INF}</td>
255+
* <td>Relative path → resolve against output directory</td>
256+
* </tr>
257+
* <tr>
258+
* <td>{@code Optional.of(Path.of("WEB-INF/classes"))}</td>
259+
* <td>{@code /home/user/myproject/target/classes}</td>
260+
* <td>{@code /home/user/myproject/target/classes/WEB-INF/classes}</td>
261+
* <td>Relative path with subdirectories</td>
262+
* </tr>
263+
* <tr>
264+
* <td>{@code Optional.of(Path.of("/tmp/custom"))}</td>
265+
* <td>{@code /home/user/myproject/target/classes}</td>
266+
* <td>{@code /tmp/custom}</td>
267+
* <td>Absolute path → use as-is (no resolution)</td>
268+
* </tr>
269+
* </tbody>
270+
* </table>
271+
* <p>
272+
* <strong>Relationship to {@link #targetPath()}:</strong>
273+
* </p>
274+
* <p>
275+
* This method is the <em>resolution</em> counterpart to {@link #targetPath()}, which is the
276+
* <em>storage</em> method. While {@code targetPath()} returns the path as configured (potentially relative),
277+
* this method returns the absolute path where files will actually be written. The separation allows:
278+
* </p>
279+
* <ul>
280+
* <li>Maven 4 API consumers to get absolute paths via this method</li>
281+
* <li>Maven 3 compatibility layer to get relative paths via {@code targetPath()} for legacy plugins</li>
282+
* <li>Implementations to store paths without premature resolution</li>
283+
* </ul>
284+
* <p>
285+
* <strong>Implementation Note:</strong> The default implementation is equivalent to:
286+
* </p>
287+
* <pre>{@code
288+
* Optional<Path> configured = targetPath();
289+
* if (configured.isPresent() && configured.get().isAbsolute()) {
290+
* return configured.get();
291+
* }
292+
* Path outputDir = project.getOutputDirectory(scope());
293+
* return configured.map(outputDir::resolve).orElse(outputDir);
294+
* }</pre>
162295
*
163-
* @param project the project to use for getting default directories
296+
* @param project the project to use for obtaining the output directory
297+
* @return the absolute path where files from {@link #directory()} should be copied
164298
*
299+
* @see #targetPath()
165300
* @see Project#getOutputDirectory(ProjectScope)
166301
*/
167302
@Nonnull

impl/maven-core/src/main/java/org/apache/maven/project/ConnectedResource.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
* A Resource wrapper that maintains a connection to the underlying project model.
3232
* When includes/excludes are modified, the changes are propagated back to the project's SourceRoots.
3333
*/
34+
@SuppressWarnings("deprecation")
3435
class ConnectedResource extends Resource {
3536
private final SourceRoot originalSourceRoot;
3637
private final ProjectScope scope;

impl/maven-core/src/test/java/org/apache/maven/project/ResourceIncludeTest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ void setUp() {
4646
// Set a dummy pom file to establish the base directory
4747
project.setFile(new java.io.File("./pom.xml"));
4848

49+
// Set build output directories
50+
project.getBuild().setOutputDirectory("target/classes");
51+
project.getBuild().setTestOutputDirectory("target/test-classes");
52+
4953
// Add a resource source root to the project
5054
project.addSourceRoot(
5155
new DefaultSourceRoot(ProjectScope.MAIN, Language.RESOURCES, Path.of("src/main/resources")));
@@ -199,7 +203,7 @@ void testTargetPathPreservedWithConnectedResource() {
199203
resourceWithTarget.setDirectory("src/main/custom");
200204
resourceWithTarget.setTargetPath("custom-output");
201205

202-
// Convert through DefaultSourceRoot to ensure targetPath extraction works
206+
// Convert through DefaultSourceRoot to ensure targetPath is preserved
203207
DefaultSourceRoot sourceRootFromResource =
204208
new DefaultSourceRoot(project.getBaseDirectory(), ProjectScope.MAIN, resourceWithTarget.getDelegate());
205209

impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,18 +142,22 @@ public static DefaultSourceRoot fromModel(
142142
source.getIncludes(),
143143
source.getExcludes(),
144144
source.isStringFiltering(),
145-
nonBlank(source.getTargetPath())
146-
.map((targetPath) ->
147-
baseDir.resolve(outputDir.apply(scope)).resolve(targetPath))
148-
.orElse(null),
145+
nonBlank(source.getTargetPath()).map(Path::of).orElse(null),
149146
source.isEnabled());
150147
}
151148

152149
/**
153150
* Creates a new instance from the given resource.
154151
* This is used for migration from the previous way of declaring resources.
152+
* <p>
153+
* <strong>Important:</strong> The {@code targetPath} from the resource is stored as-is
154+
* (converted to a {@link Path} but not resolved against any directory). This preserves
155+
* the Maven 3.x behavior where {@code targetPath} is relative to the output directory,
156+
* not the project base directory. The actual resolution happens later via
157+
* {@link SourceRoot#targetPath(Project)}.
158+
* </p>
155159
*
156-
* @param baseDir the base directory for resolving relative paths
160+
* @param baseDir the base directory for resolving relative paths (used only for the source directory)
157161
* @param scope the scope of the resource (main or test)
158162
* @param resource a resource element from the model
159163
*/
@@ -169,7 +173,7 @@ public DefaultSourceRoot(final Path baseDir, ProjectScope scope, Resource resour
169173
resource.getIncludes(),
170174
resource.getExcludes(),
171175
Boolean.parseBoolean(resource.getFiltering()),
172-
nonBlank(resource.getTargetPath()).map(baseDir::resolve).orElse(null),
176+
nonBlank(resource.getTargetPath()).map(Path::of).orElse(null),
173177
true);
174178
}
175179

@@ -220,6 +224,11 @@ public Optional<Version> targetVersion() {
220224

221225
/**
222226
* {@return an explicit target path, overriding the default value}
227+
* <p>
228+
* The returned path, if present, is stored as provided in the configuration and is typically
229+
* relative to the output directory. Use {@link #targetPath(Project)} to get the fully
230+
* resolved absolute path.
231+
* </p>
223232
*/
224233
@Override
225234
public Optional<Path> targetPath() {

0 commit comments

Comments
 (0)