1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one
3 * or more contributor license agreements. See the NOTICE file
4 * distributed with this work for additional information
5 * regarding copyright ownership. The ASF licenses this file
6 * to you under the Apache License, Version 2.0 (the
7 * "License"); you may not use this file except in compliance
8 * with the License. You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing,
13 * software distributed under the License is distributed on an
14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15 * KIND, either express or implied. See the License for the
16 * specific language governing permissions and limitations
17 * under the License.
18 */
19 package org.apache.maven.project;
20
21 import java.nio.file.Path;
22 import java.util.HashSet;
23 import java.util.List;
24 import java.util.Set;
25
26 import org.apache.maven.api.Language;
27 import org.apache.maven.api.ProjectScope;
28 import org.apache.maven.api.SourceRoot;
29 import org.apache.maven.api.model.Resource;
30 import org.apache.maven.api.model.Source;
31 import org.apache.maven.api.services.BuilderProblem.Severity;
32 import org.apache.maven.api.services.ModelBuilderResult;
33 import org.apache.maven.api.services.ModelProblem.Version;
34 import org.apache.maven.impl.DefaultSourceRoot;
35 import org.apache.maven.impl.model.DefaultModelProblem;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
38
39 /**
40 * Handles source configuration for Maven projects with unified tracking for all language/scope combinations.
41 * This class uses a flexible set-based tracking mechanism that works for any language and scope combination.
42 * <p>
43 * Key features:
44 * <ul>
45 * <li>Tracks declared sources using {@code (language, scope, module, directory)} identity</li>
46 * <li>Only tracks enabled sources - disabled sources are effectively no-ops</li>
47 * <li>Detects duplicate enabled sources and emits warnings</li>
48 * <li>Provides {@link #hasSources(Language, ProjectScope)} to check if sources exist for a combination</li>
49 * </ul>
50 *
51 * @since 4.0.0
52 */
53 final class SourceHandlingContext {
54
55 private static final Logger LOGGER = LoggerFactory.getLogger(SourceHandlingContext.class);
56
57 /**
58 * Identity key for source tracking. Two sources with the same key are considered duplicates.
59 */
60 record SourceKey(Language language, ProjectScope scope, String module, Path directory) {}
61
62 /**
63 * The {@code <source>} elements declared in the {@code <build>} elements.
64 */
65 final List<Source> sources;
66
67 private final MavenProject project;
68 private final Set<String> modules;
69 private final ModelBuilderResult result;
70 private final Set<SourceKey> declaredSources;
71
72 SourceHandlingContext(MavenProject project, ModelBuilderResult result) {
73 this.project = project;
74 this.sources = project.getBuild().getDelegate().getSources();
75 this.modules = SourceQueries.getModuleNames(sources);
76 this.result = result;
77 // Each module typically has main, test, main resources, test resources = 4 sources
78 this.declaredSources = new HashSet<>(4 * modules.size());
79 if (usesModuleSourceHierarchy()) {
80 LOGGER.trace("Found {} module(s) in the \"{}\" project: {}.", project.getId(), modules.size(), modules);
81 } else {
82 LOGGER.trace("Project \"{}\" is non-modular.", project.getId());
83 }
84 }
85
86 /**
87 * Whether the project uses module source hierarchy.
88 * Note that this is not synonymous of whether the project is modular,
89 * because it is possible to create a single Java module in a classic Maven project
90 * (i.e., using package hierarchy).
91 */
92 boolean usesModuleSourceHierarchy() {
93 return !modules.isEmpty();
94 }
95
96 /**
97 * Determines if a source root should be added to the project and tracks it for duplicate detection.
98 * <p>
99 * Rules:
100 * <ul>
101 * <li>Disabled sources are always added (they're filtered by {@code getEnabledSourceRoots()})</li>
102 * <li>First enabled source for an identity is added and tracked</li>
103 * <li>Subsequent enabled sources with same identity trigger a WARNING and are NOT added</li>
104 * </ul>
105 *
106 * @param sourceRoot the source root to evaluate
107 * @return true if the source should be added to the project, false if it's a duplicate enabled source
108 */
109 boolean shouldAddSource(SourceRoot sourceRoot) {
110 if (!sourceRoot.enabled()) {
111 // Disabled sources are always added - they're filtered out by getEnabledSourceRoots()
112 LOGGER.trace(
113 "Adding disabled source (will be filtered by getEnabledSourceRoots): lang={}, scope={}, module={}, dir={}",
114 sourceRoot.language(),
115 sourceRoot.scope(),
116 sourceRoot.module().orElse(null),
117 sourceRoot.directory());
118 return true;
119 }
120
121 // Normalize path for consistent duplicate detection (handles symlinks, relative paths)
122 Path normalizedDir = sourceRoot.directory().toAbsolutePath().normalize();
123 SourceKey key = new SourceKey(
124 sourceRoot.language(), sourceRoot.scope(), sourceRoot.module().orElse(null), normalizedDir);
125
126 if (!declaredSources.add(key)) {
127 String message = String.format(
128 "Duplicate enabled source detected: lang=%s, scope=%s, module=%s, directory=%s. "
129 + "First enabled source wins, this duplicate is ignored.",
130 key.language(), key.scope(), key.module() != null ? key.module() : "(none)", key.directory());
131 LOGGER.warn(message);
132 result.getProblemCollector()
133 .reportProblem(new DefaultModelProblem(
134 message,
135 Severity.WARNING,
136 Version.V41,
137 project.getModel().getDelegate(),
138 -1,
139 -1,
140 null));
141 return false; // Don't add duplicate enabled source
142 }
143
144 LOGGER.debug(
145 "Adding and tracking enabled source: lang={}, scope={}, module={}, dir={}",
146 key.language(),
147 key.scope(),
148 key.module(),
149 key.directory());
150 return true; // Add first enabled source with this identity
151 }
152
153 /**
154 * Checks if any enabled sources have been declared for the given language and scope combination.
155 *
156 * @param language the language to check (e.g., {@link Language#JAVA_FAMILY}, {@link Language#RESOURCES})
157 * @param scope the scope to check (e.g., {@link ProjectScope#MAIN}, {@link ProjectScope#TEST})
158 * @return true if at least one enabled source exists for this combination
159 */
160 boolean hasSources(Language language, ProjectScope scope) {
161 return declaredSources.stream().anyMatch(key -> language.equals(key.language()) && scope.equals(key.scope()));
162 }
163
164 /**
165 * {@return the source directory as defined by Maven conventions}
166 */
167 private Path getStandardSourceDirectory() {
168 return project.getBaseDirectory().resolve("src");
169 }
170
171 /**
172 * Fails the build if modular and classic (non-modular) sources are mixed within {@code <sources>}.
173 * <p>
174 * A project must be either fully modular (all sources have a module) or fully classic
175 * (no sources have a module). Mixing modular and non-modular sources within the same
176 * project is not supported because the compiler plugin cannot handle such configurations.
177 * <p>
178 * This validation checks each (language, scope) combination and reports an ERROR if
179 * both modular and non-modular sources are found.
180 */
181 void failIfMixedModularAndClassicSources() {
182 for (ProjectScope scope : List.of(ProjectScope.MAIN, ProjectScope.TEST)) {
183 for (Language language : List.of(Language.JAVA_FAMILY, Language.RESOURCES)) {
184 boolean hasModular = false;
185 boolean hasClassic = false;
186 for (SourceKey key : declaredSources) {
187 if (language.equals(key.language()) && scope.equals(key.scope())) {
188 String module = key.module();
189 hasModular |= (module != null);
190 hasClassic |= (module == null);
191 if (hasModular && hasClassic) {
192 String message = String.format(
193 "Mixed modular and classic sources detected for lang=%s, scope=%s. "
194 + "A project must be either fully modular (all sources have a module) "
195 + "or fully classic (no sources have a module).",
196 language.id(), scope.id());
197 LOGGER.error(message);
198 result.getProblemCollector()
199 .reportProblem(new DefaultModelProblem(
200 message,
201 Severity.ERROR,
202 Version.V41,
203 project.getModel().getDelegate(),
204 -1,
205 -1,
206 null));
207 break;
208 }
209 }
210 }
211 }
212 }
213 }
214
215 /**
216 * Handles resource configuration for a given scope (main or test).
217 * This method applies the resource priority rules:
218 * <ol>
219 * <li>Modular project: use resources from {@code <sources>} if present, otherwise inject defaults</li>
220 * <li>Classic project: use resources from {@code <sources>} if present, otherwise use legacy resources</li>
221 * </ol>
222 * <p>
223 * The error behavior for conflicting legacy configuration is consistent with source directory handling.
224 *
225 * @param scope the project scope (MAIN or TEST)
226 */
227 void handleResourceConfiguration(ProjectScope scope) {
228 boolean hasResourcesInSources = hasSources(Language.RESOURCES, scope);
229
230 List<Resource> resources = scope == ProjectScope.MAIN
231 ? project.getBuild().getDelegate().getResources()
232 : project.getBuild().getDelegate().getTestResources();
233
234 String scopeId = scope.id();
235 String scopeName = scope == ProjectScope.MAIN ? "Main" : "Test";
236 String legacyElement = scope == ProjectScope.MAIN ? "<resources>" : "<testResources>";
237 String sourcesConfig = scope == ProjectScope.MAIN
238 ? "<source><lang>resources</lang></source>"
239 : "<source><lang>resources</lang><scope>test</scope></source>";
240
241 if (usesModuleSourceHierarchy()) {
242 if (hasResourcesInSources) {
243 // Modular project with resources configured via <sources> - already added above
244 if (hasExplicitLegacyResources(resources, scopeId)) {
245 String message = String.format(
246 "Legacy %s element cannot be used because %s resources are configured via %s in <sources>.",
247 legacyElement, scopeId, sourcesConfig);
248 LOGGER.error(message);
249 result.getProblemCollector()
250 .reportProblem(new DefaultModelProblem(
251 message,
252 Severity.ERROR,
253 Version.V41,
254 project.getModel().getDelegate(),
255 -1,
256 -1,
257 null));
258 } else {
259 LOGGER.debug(
260 "{} resources configured via <sources> element, ignoring legacy {} element.",
261 scopeName,
262 legacyElement);
263 }
264 } else {
265 // Modular project without resources in <sources> - inject module-aware defaults
266 if (hasExplicitLegacyResources(resources, scopeId)) {
267 String message = "Legacy " + legacyElement
268 + " element cannot be used because modular sources are configured. "
269 + "Use " + sourcesConfig + " in <sources> for custom resource paths.";
270 LOGGER.error(message);
271 result.getProblemCollector()
272 .reportProblem(new DefaultModelProblem(
273 message,
274 Severity.ERROR,
275 Version.V41,
276 project.getModel().getDelegate(),
277 -1,
278 -1,
279 null));
280 }
281 for (String module : modules) {
282 project.addSourceRoot(createModularResourceRoot(module, scope));
283 }
284 if (!modules.isEmpty()) {
285 LOGGER.debug(
286 "Injected {} module-aware {} resource root(s) for modules: {}.",
287 modules.size(),
288 scopeId,
289 modules);
290 }
291 }
292 } else {
293 // Classic (non-modular) project
294 if (hasResourcesInSources) {
295 // Resources configured via <sources> - already added above
296 if (hasExplicitLegacyResources(resources, scopeId)) {
297 String message = String.format(
298 "Legacy %s element cannot be used because %s resources are configured via %s in <sources>.",
299 legacyElement, scopeId, sourcesConfig);
300 LOGGER.error(message);
301 result.getProblemCollector()
302 .reportProblem(new DefaultModelProblem(
303 message,
304 Severity.ERROR,
305 Version.V41,
306 project.getModel().getDelegate(),
307 -1,
308 -1,
309 null));
310 } else {
311 LOGGER.debug(
312 "{} resources configured via <sources> element, ignoring legacy {} element.",
313 scopeName,
314 legacyElement);
315 }
316 } else {
317 // Use legacy resources element
318 LOGGER.debug(
319 "Using explicit or default {} resources ({} resources configured).", scopeId, resources.size());
320 Path baseDir = project.getBaseDirectory();
321 for (Resource resource : resources) {
322 project.addSourceRoot(new DefaultSourceRoot(baseDir, scope, resource));
323 }
324 }
325 }
326 }
327
328 /**
329 * Creates a DefaultSourceRoot for module-aware resource directories.
330 * Generates paths following the pattern: {@code src/<module>/<scope>/resources}
331 *
332 * @param module module name
333 * @param scope project scope (main or test)
334 * @return configured DefaultSourceRoot for the module's resources
335 */
336 private DefaultSourceRoot createModularResourceRoot(String module, ProjectScope scope) {
337 Path resourceDir =
338 getStandardSourceDirectory().resolve(module).resolve(scope.id()).resolve("resources");
339
340 return new DefaultSourceRoot(
341 scope,
342 Language.RESOURCES,
343 module,
344 null, // targetVersion
345 resourceDir,
346 null, // includes
347 null, // excludes
348 false, // stringFiltering
349 Path.of(module), // targetPath - resources go to target/classes/<module>
350 true // enabled
351 );
352 }
353
354 /**
355 * Checks if the given resource list contains explicit legacy resources that differ
356 * from Super POM defaults. Super POM defaults are: src/{scope}/resources and src/{scope}/resources-filtered
357 *
358 * @param resources list of resources to check
359 * @param scope scope (main or test)
360 * @return true if explicit legacy resources are present that conflict with modular sources
361 */
362 private boolean hasExplicitLegacyResources(List<Resource> resources, String scope) {
363 if (resources.isEmpty()) {
364 return false; // No resources means no explicit legacy resources to warn about
365 }
366
367 // Super POM default paths
368 Path srcDir = getStandardSourceDirectory();
369 String defaultPath = srcDir.resolve(scope).resolve("resources").toString();
370 String defaultFilteredPath =
371 srcDir.resolve(scope).resolve("resources-filtered").toString();
372
373 // Check if any resource differs from Super POM defaults
374 for (Resource resource : resources) {
375 String resourceDir = resource.getDirectory();
376 if (resourceDir != null && !resourceDir.equals(defaultPath) && !resourceDir.equals(defaultFilteredPath)) {
377 // Found an explicit legacy resource
378 return true;
379 }
380 }
381
382 return false;
383 }
384 }