View Javadoc
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 }