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 5bfa2d3f3274..1fcf644cef22 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 @@ -27,6 +27,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.AbstractMap; import java.util.ArrayList; @@ -63,7 +64,6 @@ import org.apache.maven.api.model.Plugin; import org.apache.maven.api.model.Profile; import org.apache.maven.api.model.ReportPlugin; -import org.apache.maven.api.model.Resource; import org.apache.maven.api.services.ArtifactResolver; import org.apache.maven.api.services.ArtifactResolverException; import org.apache.maven.api.services.ArtifactResolverRequest; @@ -520,7 +520,7 @@ List doBuild(List pomFiles, boolean recursive) { return pomFiles.stream() .map(pomFile -> build(pomFile, recursive)) .flatMap(List::stream) - .collect(Collectors.toList()); + .toList(); } finally { Thread.currentThread().setContextClassLoader(oldContextClassLoader); } @@ -566,7 +566,7 @@ private List build(File pomFile, boolean recursive) { project.setCollectedProjects(results(r) .filter(cr -> cr != r && cr.getEffectiveModel() != null) .map(cr -> projectIndex.get(cr.getEffectiveModel().getId())) - .collect(Collectors.toList())); + .toList()); DependencyResolutionResult resolutionResult = null; if (request.isResolveDependencies()) { @@ -660,46 +660,75 @@ private void initProject(MavenProject project, ModelBuilderResult result) { return build.getDirectory(); } }; - boolean hasScript = false; - boolean hasMain = false; - boolean hasTest = false; + // Extract modules from sources to detect modular projects + Set modules = extractModules(sources); + boolean isModularProject = !modules.isEmpty(); + + logger.trace( + "Module detection for project {}: found {} module(s) {} - modular project: {}.", + project.getId(), + modules.size(), + modules, + isModularProject); + + // Create source handling context for unified tracking of all lang/scope combinations + SourceHandlingContext sourceContext = + new SourceHandlingContext(project, baseDir, modules, isModularProject, result); + + // Process all sources, tracking enabled ones and detecting duplicates for (var source : sources) { - var src = DefaultSourceRoot.fromModel(session, baseDir, outputDirectory, source); - project.addSourceRoot(src); - Language language = src.language(); - if (Language.JAVA_FAMILY.equals(language)) { - ProjectScope scope = src.scope(); - if (ProjectScope.MAIN.equals(scope)) { - hasMain = true; - } else { - hasTest |= ProjectScope.TEST.equals(scope); - } - } else { - hasScript |= Language.SCRIPT.equals(language); + var sourceRoot = DefaultSourceRoot.fromModel(session, baseDir, outputDirectory, source); + // Track enabled sources for duplicate detection and hasSources() queries + // Only add source if it's not a duplicate enabled source (first enabled wins) + if (sourceContext.shouldAddSource(sourceRoot)) { + project.addSourceRoot(sourceRoot); } } + /* * `sourceDirectory`, `testSourceDirectory` and `scriptSourceDirectory` - * are ignored if the POM file contains at least one element + * are ignored if the POM file contains at least one enabled element * for the corresponding scope and language. This rule exists because * Maven provides default values for those elements which may conflict * with user's configuration. + * + * Additionally, for modular projects, legacy directories are unconditionally + * ignored because it is not clear how to dispatch their content between + * different modules. A warning is emitted if these properties are explicitly set. */ - if (!hasScript) { + if (!sourceContext.hasSources(Language.SCRIPT, ProjectScope.MAIN)) { project.addScriptSourceRoot(build.getScriptSourceDirectory()); } - if (!hasMain) { - project.addCompileSourceRoot(build.getSourceDirectory()); - } - if (!hasTest) { - project.addTestCompileSourceRoot(build.getTestSourceDirectory()); - } - for (Resource resource : project.getBuild().getDelegate().getResources()) { - project.addSourceRoot(new DefaultSourceRoot(baseDir, ProjectScope.MAIN, resource)); - } - for (Resource resource : project.getBuild().getDelegate().getTestResources()) { - project.addSourceRoot(new DefaultSourceRoot(baseDir, ProjectScope.TEST, resource)); + if (isModularProject) { + // Modular projects: unconditionally ignore legacy directories, warn if explicitly set + warnIfExplicitLegacyDirectory( + build.getSourceDirectory(), + baseDir.resolve("src/main/java"), + "", + project.getId(), + result); + warnIfExplicitLegacyDirectory( + build.getTestSourceDirectory(), + baseDir.resolve("src/test/java"), + "", + project.getId(), + result); + } else { + // Classic projects: use legacy directories if no sources defined in + if (!sourceContext.hasSources(Language.JAVA_FAMILY, ProjectScope.MAIN)) { + project.addCompileSourceRoot(build.getSourceDirectory()); + } + if (!sourceContext.hasSources(Language.JAVA_FAMILY, ProjectScope.TEST)) { + project.addTestCompileSourceRoot(build.getTestSourceDirectory()); + } } + + // Validate that modular and classic sources are not mixed within + sourceContext.validateNoMixedModularAndClassicSources(); + + // Handle main and test resources using unified source handling + sourceContext.handleResourceConfiguration(ProjectScope.MAIN); + sourceContext.handleResourceConfiguration(ProjectScope.TEST); } project.setActiveProfiles( @@ -870,6 +899,49 @@ private void initProject(MavenProject project, ModelBuilderResult result) { project.setRemoteArtifactRepositories(remoteRepositories); } + /** + * Warns about legacy directory usage in a modular project. Two cases are handled: + *
    + *
  • Case 1: The default legacy directory exists on the filesystem (e.g., src/main/java exists)
  • + *
  • Case 2: An explicit legacy directory is configured that differs from the default
  • + *
+ * Legacy directories are unconditionally ignored in modular projects because it is not clear + * how to dispatch their content between different modules. + */ + private void warnIfExplicitLegacyDirectory( + String configuredDir, + Path defaultDir, + String elementName, + String projectId, + ModelBuilderResult result) { + if (configuredDir != null) { + Path configuredPath = Path.of(configuredDir).toAbsolutePath().normalize(); + Path defaultPath = defaultDir.toAbsolutePath().normalize(); + if (!configuredPath.equals(defaultPath)) { + // Case 2: Explicit configuration differs from default - always warn + String message = String.format( + "Legacy %s is ignored in modular project %s. " + + "In modular projects, source directories must be defined via " + + "with a module element for each module.", + elementName, projectId); + logger.warn(message); + result.getProblemCollector() + .reportProblem(new org.apache.maven.impl.model.DefaultModelProblem( + message, Severity.WARNING, Version.V41, null, -1, -1, null)); + } else if (Files.isDirectory(defaultPath)) { + // Case 1: Default configuration, but the default directory exists on filesystem + String message = String.format( + "Legacy %s '%s' exists but is ignored in modular project %s. " + + "In modular projects, source directories must be defined via .", + elementName, defaultPath, projectId); + logger.warn(message); + result.getProblemCollector() + .reportProblem(new org.apache.maven.impl.model.DefaultModelProblem( + message, Severity.WARNING, Version.V41, null, -1, -1, null)); + } + } + } + private void initParent(MavenProject project, ModelBuilderResult result) { Model parentModel = result.getParentModel(); @@ -1011,8 +1083,8 @@ private DependencyResolutionResult resolveDependencies(MavenProject project) { } } - private List getProfileIds(List profiles) { - return profiles.stream().map(Profile::getId).collect(Collectors.toList()); + private static List getProfileIds(List profiles) { + return profiles.stream().map(Profile::getId).toList(); } private static ModelSource createStubModelSource(Artifact artifact) { @@ -1093,6 +1165,22 @@ public Set> entrySet() { } } + /** + * Extracts unique module names from the given list of source elements. + * A project is considered modular if it has at least one module name. + * + * @param sources list of source elements from the build + * @return set of non-blank module names + */ + private static Set extractModules(List sources) { + return sources.stream() + .map(org.apache.maven.api.model.Source::getModule) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + } + private Model injectLifecycleBindings( Model model, ModelBuilderRequest request, diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java new file mode 100644 index 000000000000..e7691eb86bcf --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java @@ -0,0 +1,348 @@ +/* + * 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.project; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.maven.api.Language; +import org.apache.maven.api.ProjectScope; +import org.apache.maven.api.SourceRoot; +import org.apache.maven.api.model.Resource; +import org.apache.maven.api.services.BuilderProblem.Severity; +import org.apache.maven.api.services.ModelBuilderResult; +import org.apache.maven.api.services.ModelProblem.Version; +import org.apache.maven.impl.DefaultSourceRoot; +import org.apache.maven.impl.model.DefaultModelProblem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles source configuration for Maven projects with unified tracking for all language/scope combinations. + *

+ * This class replaces the previous approach of hardcoded boolean flags (hasMain, hasTest, etc.) + * with a flexible set-based tracking mechanism that works for any language and scope combination. + *

+ * Key features: + *

    + *
  • Tracks declared sources using {@code (language, scope, module, directory)} identity
  • + *
  • Only tracks enabled sources - disabled sources are effectively no-ops
  • + *
  • Detects duplicate enabled sources and emits warnings
  • + *
  • Provides {@link #hasSources(Language, ProjectScope)} to check if sources exist for a combination
  • + *
+ * + * @since 4.0.0 + */ +class SourceHandlingContext { + + private static final Logger LOGGER = LoggerFactory.getLogger(SourceHandlingContext.class); + + /** + * Identity key for source tracking. Two sources with the same key are considered duplicates. + */ + record SourceKey(Language language, ProjectScope scope, String module, Path directory) {} + + private final MavenProject project; + private final Path baseDir; + private final Set modules; + private final boolean modularProject; + private final ModelBuilderResult result; + private final Set declaredSources; + + SourceHandlingContext( + MavenProject project, + Path baseDir, + Set modules, + boolean modularProject, + ModelBuilderResult result) { + this.project = project; + this.baseDir = baseDir; + this.modules = modules; + this.modularProject = modularProject; + this.result = result; + // Each module typically has main, test, main resources, test resources = 4 sources + this.declaredSources = new HashSet<>(4 * modules.size()); + } + + /** + * Determines if a source root should be added to the project and tracks it for duplicate detection. + *

+ * Rules: + *

    + *
  • Disabled sources are always added (they're filtered by {@code getEnabledSourceRoots()})
  • + *
  • First enabled source for an identity is added and tracked
  • + *
  • Subsequent enabled sources with same identity trigger a WARNING and are NOT added
  • + *
+ * + * @param sourceRoot the source root to evaluate + * @return true if the source should be added to the project, false if it's a duplicate enabled source + */ + boolean shouldAddSource(SourceRoot sourceRoot) { + if (!sourceRoot.enabled()) { + // Disabled sources are always added - they're filtered out by getEnabledSourceRoots() + LOGGER.trace( + "Adding disabled source (will be filtered by getEnabledSourceRoots): lang={}, scope={}, module={}, dir={}", + sourceRoot.language(), + sourceRoot.scope(), + sourceRoot.module().orElse(null), + sourceRoot.directory()); + return true; + } + + // Normalize path for consistent duplicate detection (handles symlinks, relative paths) + Path normalizedDir = sourceRoot.directory().toAbsolutePath().normalize(); + SourceKey key = new SourceKey( + sourceRoot.language(), sourceRoot.scope(), sourceRoot.module().orElse(null), normalizedDir); + + if (declaredSources.contains(key)) { + String message = String.format( + "Duplicate enabled source detected: lang=%s, scope=%s, module=%s, directory=%s. " + + "First enabled source wins, this duplicate is ignored.", + key.language(), key.scope(), key.module() != null ? key.module() : "(none)", key.directory()); + LOGGER.warn(message); + result.getProblemCollector() + .reportProblem(new DefaultModelProblem( + message, + Severity.WARNING, + Version.V41, + project.getModel().getDelegate(), + -1, + -1, + null)); + return false; // Don't add duplicate enabled source + } + + declaredSources.add(key); + LOGGER.debug( + "Adding and tracking enabled source: lang={}, scope={}, module={}, dir={}", + key.language(), + key.scope(), + key.module(), + key.directory()); + return true; // Add first enabled source with this identity + } + + /** + * Checks if any enabled sources have been declared for the given language and scope combination. + * + * @param language the language to check (e.g., {@link Language#JAVA_FAMILY}, {@link Language#RESOURCES}) + * @param scope the scope to check (e.g., {@link ProjectScope#MAIN}, {@link ProjectScope#TEST}) + * @return true if at least one enabled source exists for this combination + */ + boolean hasSources(Language language, ProjectScope scope) { + return declaredSources.stream().anyMatch(key -> language.equals(key.language()) && scope.equals(key.scope())); + } + + /** + * Validates that a project does not mix modular and classic (non-modular) sources. + *

+ * A project must be either fully modular (all sources have a module) or fully classic + * (no sources have a module). Mixing modular and non-modular sources within the same + * project is not supported because the compiler plugin cannot handle such configurations. + *

+ * This validation checks each (language, scope) combination and reports an ERROR if + * both modular and non-modular sources are found. + */ + void validateNoMixedModularAndClassicSources() { + for (ProjectScope scope : List.of(ProjectScope.MAIN, ProjectScope.TEST)) { + for (Language language : List.of(Language.JAVA_FAMILY, Language.RESOURCES)) { + boolean hasModular = declaredSources.stream() + .anyMatch(key -> + language.equals(key.language()) && scope.equals(key.scope()) && key.module() != null); + boolean hasClassic = declaredSources.stream() + .anyMatch(key -> + language.equals(key.language()) && scope.equals(key.scope()) && key.module() == null); + + if (hasModular && hasClassic) { + String message = String.format( + "Mixed modular and classic sources detected for lang=%s, scope=%s. " + + "A project must be either fully modular (all sources have a module) " + + "or fully classic (no sources have a module). " + + "The compiler plugin cannot handle mixed configurations.", + language.id(), scope.id()); + LOGGER.error(message); + result.getProblemCollector() + .reportProblem(new DefaultModelProblem( + message, + Severity.ERROR, + Version.V41, + project.getModel().getDelegate(), + -1, + -1, + null)); + } + } + } + } + + /** + * Handles resource configuration for a given scope (main or test). + * This method applies the resource priority rules: + *

    + *
  1. Modular project: use resources from {@code } if present, otherwise inject defaults
  2. + *
  3. Classic project: use resources from {@code } if present, otherwise use legacy resources
  4. + *
+ * + * @param scope the project scope (MAIN or TEST) + */ + void handleResourceConfiguration(ProjectScope scope) { + boolean hasResourcesInSources = hasSources(Language.RESOURCES, scope); + + List resources = scope == ProjectScope.MAIN + ? project.getBuild().getDelegate().getResources() + : project.getBuild().getDelegate().getTestResources(); + + String scopeId = scope.id(); + String scopeName = scope == ProjectScope.MAIN ? "Main" : "Test"; + String legacyElement = scope == ProjectScope.MAIN ? "" : ""; + String sourcesConfig = scope == ProjectScope.MAIN + ? "resources" + : "resourcestest"; + + if (modularProject) { + if (hasResourcesInSources) { + // Modular project with resources configured via - already added above + if (hasExplicitLegacyResources(resources, scopeId)) { + LOGGER.warn( + "Legacy {} element is ignored because {} resources are configured via {} in .", + legacyElement, + scopeId, + sourcesConfig); + } else { + LOGGER.debug( + "{} resources configured via element, ignoring legacy {} element.", + scopeName, + legacyElement); + } + } else { + // Modular project without resources in - inject module-aware defaults + if (hasExplicitLegacyResources(resources, scopeId)) { + String message = "Legacy " + legacyElement + + " element is ignored because modular sources are configured. " + + "Use " + sourcesConfig + " in for custom resource paths."; + LOGGER.warn(message); + result.getProblemCollector() + .reportProblem(new DefaultModelProblem( + message, + Severity.WARNING, + Version.V41, + project.getModel().getDelegate(), + -1, + -1, + null)); + } + for (String module : modules) { + project.addSourceRoot(createModularResourceRoot(module, scope)); + } + if (!modules.isEmpty()) { + LOGGER.debug( + "Injected {} module-aware {} resource root(s) for modules: {}.", + modules.size(), + scopeId, + modules); + } + } + } else { + // Classic (non-modular) project + if (hasResourcesInSources) { + // Resources configured via - already added above + if (hasExplicitLegacyResources(resources, scopeId)) { + LOGGER.warn( + "Legacy {} element is ignored because {} resources are configured via {} in .", + legacyElement, + scopeId, + sourcesConfig); + } else { + LOGGER.debug( + "{} resources configured via element, ignoring legacy {} element.", + scopeName, + legacyElement); + } + } else { + // Use legacy resources element + LOGGER.debug( + "Using explicit or default {} resources ({} resources configured).", scopeId, resources.size()); + for (Resource resource : resources) { + project.addSourceRoot(new DefaultSourceRoot(baseDir, scope, resource)); + } + } + } + } + + /** + * Creates a DefaultSourceRoot for module-aware resource directories. + * Generates paths following the pattern: {@code src///resources} + * + * @param module module name + * @param scope project scope (main or test) + * @return configured DefaultSourceRoot for the module's resources + */ + private DefaultSourceRoot createModularResourceRoot(String module, ProjectScope scope) { + Path resourceDir = + baseDir.resolve("src").resolve(module).resolve(scope.id()).resolve("resources"); + + return new DefaultSourceRoot( + scope, + Language.RESOURCES, + module, + null, // targetVersion + resourceDir, + null, // includes + null, // excludes + false, // stringFiltering + Path.of(module), // targetPath - resources go to target/classes/ + true // enabled + ); + } + + /** + * Checks if the given resource list contains explicit legacy resources that differ + * from Super POM defaults. Super POM defaults are: src/{scope}/resources and src/{scope}/resources-filtered + * + * @param resources list of resources to check + * @param scope scope (main or test) + * @return true if explicit legacy resources are present that would be ignored + */ + private boolean hasExplicitLegacyResources(List resources, String scope) { + if (resources.isEmpty()) { + return false; // No resources means no explicit legacy resources to warn about + } + + // Super POM default paths + String defaultPath = + baseDir.resolve("src").resolve(scope).resolve("resources").toString(); + String defaultFilteredPath = baseDir.resolve("src") + .resolve(scope) + .resolve("resources-filtered") + .toString(); + + // Check if any resource differs from Super POM defaults + for (Resource resource : resources) { + String resourceDir = resource.getDirectory(); + if (resourceDir != null && !resourceDir.equals(defaultPath) && !resourceDir.equals(defaultFilteredPath)) { + // Found an explicit legacy resource + return true; + } + } + + return false; + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java index 541ac8063c60..bc257567f32f 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java @@ -25,10 +25,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import org.apache.maven.AbstractCoreMavenComponentTestCase; +import org.apache.maven.api.Language; +import org.apache.maven.api.ProjectScope; +import org.apache.maven.api.SourceRoot; import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Dependency; import org.apache.maven.model.InputLocation; @@ -381,4 +387,354 @@ void testLocationTrackingResolution() throws Exception { assertEquals( "org.apache.maven.its:parent:0.1", pluginLocation.getSource().getModelId()); } + /** + * Tests that a project with multiple modules defined in sources is detected as modular, + * and module-aware resource roots are injected for each module. + *

+ * Acceptance Criterion: AC2 (unified source tracking for all lang/scope combinations) + * + * @see Issue #11612 + */ + @Test + void testModularSourcesInjectResourceRoots() throws Exception { + File pom = getProject("modular-sources"); + + MavenSession session = createMavenSession(pom); + MavenProject project = session.getCurrentProject(); + + // Get all resource source roots for main scope + List mainResourceRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES) + .toList(); + + // Should have resource roots for both modules + Set modules = mainResourceRoots.stream() + .map(SourceRoot::module) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + assertEquals(2, modules.size(), "Should have resource roots for 2 modules"); + assertTrue(modules.contains("org.foo.moduleA"), "Should have resource root for moduleA"); + assertTrue(modules.contains("org.foo.moduleB"), "Should have resource root for moduleB"); + + // Get all resource source roots for test scope + List testResourceRoots = project.getEnabledSourceRoots(ProjectScope.TEST, Language.RESOURCES) + .toList(); + + // Should have test resource roots for both modules + Set testModules = testResourceRoots.stream() + .map(SourceRoot::module) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + assertEquals(2, testModules.size(), "Should have test resource roots for 2 modules"); + assertTrue(testModules.contains("org.foo.moduleA"), "Should have test resource root for moduleA"); + assertTrue(testModules.contains("org.foo.moduleB"), "Should have test resource root for moduleB"); + } + + /** + * Tests that when modular sources are configured alongside explicit legacy resources, + * the legacy resources are ignored and a warning is issued. + *

+ * This verifies the behavior described in the design: + * - Modular projects with explicit legacy {@code } configuration should issue a warning + * - The modular resource roots are injected instead of using the legacy configuration + *

+ * Acceptance Criterion: AC2 (unified source tracking for all lang/scope combinations) + * + * @see Issue #11612 + */ + @Test + void testModularSourcesWithExplicitResourcesIssuesWarning() throws Exception { + File pom = getProject("modular-sources-with-explicit-resources"); + + MavenSession mavenSession = createMavenSession(null); + ProjectBuildingRequest configuration = new DefaultProjectBuildingRequest(); + configuration.setRepositorySession(mavenSession.getRepositorySession()); + + ProjectBuildingResult result = getContainer() + .lookup(org.apache.maven.project.ProjectBuilder.class) + .build(pom, configuration); + + MavenProject project = result.getProject(); + + // Verify warnings are issued for ignored legacy resources + List warnings = result.getProblems().stream() + .filter(p -> p.getSeverity() == ModelProblem.Severity.WARNING) + .filter(p -> p.getMessage().contains("Legacy") && p.getMessage().contains("ignored")) + .toList(); + + assertEquals(2, warnings.size(), "Should have 2 warnings (one for resources, one for testResources)"); + assertTrue( + warnings.stream().anyMatch(w -> w.getMessage().contains("")), + "Should warn about ignored "); + assertTrue( + warnings.stream().anyMatch(w -> w.getMessage().contains("")), + "Should warn about ignored "); + + // Verify modular resources are still injected correctly + List mainResourceRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES) + .toList(); + + assertEquals(2, mainResourceRoots.size(), "Should have 2 modular resource roots (one per module)"); + + Set mainModules = mainResourceRoots.stream() + .map(SourceRoot::module) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + assertEquals(2, mainModules.size(), "Should have resource roots for 2 modules"); + assertTrue(mainModules.contains("org.foo.moduleA"), "Should have resource root for moduleA"); + assertTrue(mainModules.contains("org.foo.moduleB"), "Should have resource root for moduleB"); + } + + /** + * Tests that legacy sourceDirectory and testSourceDirectory are ignored in modular projects. + *

+ * In modular projects, legacy directories are unconditionally ignored because it is not clear + * how to dispatch their content between different modules. A warning is emitted if these + * properties are explicitly set (differ from Super POM defaults). + *

+ * This verifies: + * - WARNINGs are emitted for explicitly set legacy directories in modular projects + * - sourceDirectory and testSourceDirectory are both ignored + * - Only modular sources from {@code } are used + *

+ * Acceptance Criteria: + * - AC1 (boolean flags eliminated - uses hasSources() for main/test detection) + * - AC7 (legacy directories warning - {@code } and {@code } + * are unconditionally ignored with a WARNING in modular projects) + * + * @see Issue #11612 + */ + @Test + void testMixedSourcesModularMainClassicTest() throws Exception { + File pom = getProject("mixed-sources"); + + MavenSession mavenSession = createMavenSession(null); + ProjectBuildingRequest configuration = new DefaultProjectBuildingRequest(); + configuration.setRepositorySession(mavenSession.getRepositorySession()); + + ProjectBuildingResult result = getContainer() + .lookup(org.apache.maven.project.ProjectBuilder.class) + .build(pom, configuration); + + MavenProject project = result.getProject(); + + // Verify WARNINGs are emitted for explicitly set legacy directories + List warnings = result.getProblems().stream() + .filter(p -> p.getSeverity() == ModelProblem.Severity.WARNING) + .filter(p -> p.getMessage().contains("Legacy") && p.getMessage().contains("ignored in modular project")) + .toList(); + + // Should have 2 warnings: one for sourceDirectory, one for testSourceDirectory + assertEquals(2, warnings.size(), "Should have 2 warnings for ignored legacy directories"); + assertTrue( + warnings.stream().anyMatch(w -> w.getMessage().contains("")), + "Should warn about ignored "); + assertTrue( + warnings.stream().anyMatch(w -> w.getMessage().contains("")), + "Should warn about ignored "); + + // Get main Java source roots - should have modular sources, not classic sourceDirectory + List mainJavaRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY) + .toList(); + + // Should have 2 modular main Java sources (moduleA and moduleB) + assertEquals(2, mainJavaRoots.size(), "Should have 2 modular main Java source roots"); + + Set mainModules = mainJavaRoots.stream() + .map(SourceRoot::module) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + assertEquals(2, mainModules.size(), "Should have main sources for 2 modules"); + assertTrue(mainModules.contains("org.foo.moduleA"), "Should have main source for moduleA"); + assertTrue(mainModules.contains("org.foo.moduleB"), "Should have main source for moduleB"); + + // Verify the classic sourceDirectory is NOT used (should be ignored) + boolean hasClassicMainSource = mainJavaRoots.stream().anyMatch(sr -> sr.directory() + .toString() + .replace(File.separatorChar, '/') + .contains("src/classic/main/java")); + assertTrue(!hasClassicMainSource, "Classic sourceDirectory should be ignored"); + + // Test sources should NOT be added (legacy testSourceDirectory is ignored in modular projects) + List testJavaRoots = project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY) + .toList(); + assertEquals(0, testJavaRoots.size(), "Should have no test Java sources (legacy is ignored)"); + } + + /** + * Tests that mixing modular and non-modular sources within {@code } is not allowed. + *

+ * A project must be either fully modular (all sources have a module) or fully classic + * (no sources have a module). Mixing them within the same project is not supported + * because the compiler plugin cannot handle such configurations. + *

+ * This verifies: + * - An ERROR is reported when both modular and non-modular sources exist in {@code } + * - sourceDirectory is ignored because {@code } exists + *

+ * Acceptance Criteria: + * - AC1 (boolean flags eliminated - uses hasSources() for source detection) + * - AC6 (mixed sources error - mixing modular and classic sources within {@code } + * triggers an ERROR) + * + * @see Issue #11612 + */ + @Test + void testSourcesMixedModulesWithinSources() throws Exception { + File pom = getProject("sources-mixed-modules"); + + MavenSession mavenSession = createMavenSession(null); + ProjectBuildingRequest configuration = new DefaultProjectBuildingRequest(); + configuration.setRepositorySession(mavenSession.getRepositorySession()); + + ProjectBuildingResult result = getContainer() + .lookup(org.apache.maven.project.ProjectBuilder.class) + .build(pom, configuration); + + // Verify an ERROR is reported for mixing modular and non-modular sources + List errors = result.getProblems().stream() + .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR) + .filter(p -> p.getMessage().contains("Mixed modular and classic sources")) + .toList(); + + assertEquals(1, errors.size(), "Should have 1 error for mixed modular/classic configuration"); + assertTrue(errors.get(0).getMessage().contains("lang=java"), "Error should mention java language"); + assertTrue(errors.get(0).getMessage().contains("scope=main"), "Error should mention main scope"); + } + + /** + * Tests that multiple source directories for the same (lang, scope, module) combination + * are allowed and all are added as source roots. + *

+ * This is a valid use case for Phase 2: users may have generated sources alongside regular sources, + * both belonging to the same module. Different directories = different identities = not duplicates. + *

+ * Acceptance Criterion: AC2 (unified source tracking - multiple directories per module supported) + * + * @see Issue #11612 + */ + @Test + void testMultipleDirectoriesSameModule() throws Exception { + File pom = getProject("multiple-directories-same-module"); + + MavenSession session = createMavenSession(pom); + MavenProject project = session.getCurrentProject(); + + // Get main Java source roots + List mainJavaRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY) + .toList(); + + // Should have 2 main sources: both for com.example.app but different directories + assertEquals(2, mainJavaRoots.size(), "Should have 2 main Java source roots for same module"); + + // Both should be for the same module + long moduleCount = mainJavaRoots.stream() + .filter(sr -> "com.example.app".equals(sr.module().orElse(null))) + .count(); + assertEquals(2, moduleCount, "Both main sources should be for com.example.app module"); + + // One should be implicit directory, one should be generated-sources + boolean hasImplicitDir = mainJavaRoots.stream().anyMatch(sr -> sr.directory() + .toString() + .replace(File.separatorChar, '/') + .contains("src/com.example.app/main/java")); + boolean hasGeneratedDir = mainJavaRoots.stream().anyMatch(sr -> sr.directory() + .toString() + .replace(File.separatorChar, '/') + .contains("target/generated-sources/com.example.app/java")); + + assertTrue(hasImplicitDir, "Should have implicit source directory for module"); + assertTrue(hasGeneratedDir, "Should have generated-sources directory for module"); + + // Get test Java source roots + List testJavaRoots = project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY) + .toList(); + + // Should have 2 test sources: both for com.example.app + assertEquals(2, testJavaRoots.size(), "Should have 2 test Java source roots for same module"); + + // Both test sources should be for the same module + long testModuleCount = testJavaRoots.stream() + .filter(sr -> "com.example.app".equals(sr.module().orElse(null))) + .count(); + assertEquals(2, testModuleCount, "Both test sources should be for com.example.app module"); + } + + /** + * Tests duplicate handling with enabled discriminator. + *

+ * Test scenario: + * - Same (lang, scope, module, directory) with enabled=true appearing twice → triggers WARNING + * - Same identity with enabled=false → should be filtered out (disabled sources are no-ops) + * - Different modules should be added normally + *

+ * Verifies: + * - First enabled source wins, subsequent duplicates trigger WARNING + * - Disabled sources don't count as duplicates + * - Different modules are unaffected + *

+ * Acceptance Criteria: + * - AC3 (duplicate detection - duplicates trigger WARNING) + * - AC4 (first enabled wins - duplicates are skipped) + * - AC5 (disabled sources unchanged - still added but filtered by getEnabledSourceRoots) + * + * @see Issue #11612 + */ + @Test + void testDuplicateEnabledSources() throws Exception { + File pom = getProject("duplicate-enabled-sources"); + + MavenSession mavenSession = createMavenSession(null); + ProjectBuildingRequest configuration = new DefaultProjectBuildingRequest(); + configuration.setRepositorySession(mavenSession.getRepositorySession()); + + ProjectBuildingResult result = getContainer() + .lookup(org.apache.maven.project.ProjectBuilder.class) + .build(pom, configuration); + + MavenProject project = result.getProject(); + + // Verify warnings are issued for duplicate enabled sources + List duplicateWarnings = result.getProblems().stream() + .filter(p -> p.getSeverity() == ModelProblem.Severity.WARNING) + .filter(p -> p.getMessage().contains("Duplicate enabled source")) + .toList(); + + // We have 2 duplicate pairs: main scope and test scope for com.example.dup + assertEquals(2, duplicateWarnings.size(), "Should have 2 duplicate warnings (main and test scope)"); + + // Get main Java source roots + List mainJavaRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY) + .toList(); + + // Should have 2 main sources: 1 for com.example.dup (first wins) + 1 for com.example.other + // Note: MavenProject.addSourceRoot still adds all sources, but tracking only counts first enabled + assertEquals(2, mainJavaRoots.size(), "Should have 2 main Java source roots"); + + // Verify com.example.other module is present + boolean hasOtherModule = mainJavaRoots.stream() + .anyMatch(sr -> "com.example.other".equals(sr.module().orElse(null))); + assertTrue(hasOtherModule, "Should have source root for com.example.other module"); + + // Verify com.example.dup module is present (first enabled wins) + boolean hasDupModule = mainJavaRoots.stream() + .anyMatch(sr -> "com.example.dup".equals(sr.module().orElse(null))); + assertTrue(hasDupModule, "Should have source root for com.example.dup module"); + + // Get test Java source roots + List testJavaRoots = project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY) + .toList(); + + // Test scope has 1 source for com.example.dup (first wins) + assertEquals(1, testJavaRoots.size(), "Should have 1 test Java source root"); + + // Verify it's for the dup module + assertEquals( + "com.example.dup", + testJavaRoots.get(0).module().orElse(null), + "Test source root should be for com.example.dup module"); + } } diff --git a/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml b/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml new file mode 100644 index 000000000000..42d48ddcdce0 --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml @@ -0,0 +1,64 @@ + + + + 4.1.0 + + org.apache.maven.tests + duplicate-enabled-sources-test + 1.0-SNAPSHOT + jar + + + + + + main + java + com.example.dup + true + + + + main + java + com.example.dup + true + + + + main + java + com.example.dup + false + + + + main + java + com.example.other + + + + test + java + com.example.dup + true + + + test + java + com.example.dup + true + + + + diff --git a/impl/maven-core/src/test/projects/project-builder/mixed-sources/pom.xml b/impl/maven-core/src/test/projects/project-builder/mixed-sources/pom.xml new file mode 100644 index 000000000000..caa10d988502 --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/mixed-sources/pom.xml @@ -0,0 +1,41 @@ + + + + 4.1.0 + + org.apache.maven.tests + mixed-sources-test + 1.0-SNAPSHOT + jar + + + + src/classic/main/java + + src/classic/test/java + + + + + main + java + org.foo.moduleA + + + main + java + org.foo.moduleB + + + + + diff --git a/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml b/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml new file mode 100644 index 000000000000..d2bd1a614b3f --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml @@ -0,0 +1,39 @@ + + + 4.1.0 + + org.apache.maven.tests + modular-sources-explicit-resources-test + 1.0-SNAPSHOT + jar + + + + + + main + java + org.foo.moduleA + + + + main + java + org.foo.moduleB + + + + + + src/custom/resources + + + + + src/custom/test-resources + + + + \ No newline at end of file diff --git a/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml b/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml new file mode 100644 index 000000000000..2f9b1e7b0371 --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml @@ -0,0 +1,40 @@ + + + 4.1.0 + + org.apache.maven.tests + modular-sources-test + 1.0-SNAPSHOT + jar + + + + + + main + java + org.foo.moduleA + + + + test + java + org.foo.moduleA + + + + main + java + org.foo.moduleB + + + + test + java + org.foo.moduleB + + + + \ No newline at end of file diff --git a/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml b/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml new file mode 100644 index 000000000000..a1128eaa567b --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml @@ -0,0 +1,51 @@ + + + + 4.1.0 + + org.apache.maven.tests + multiple-directories-same-module-test + 1.0-SNAPSHOT + jar + + + + + + main + java + com.example.app + + + + main + java + com.example.app + target/generated-sources/com.example.app/java + + + + test + java + com.example.app + + + + test + java + com.example.app + target/generated-test-sources/com.example.app/java + + + + diff --git a/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml b/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml new file mode 100644 index 000000000000..0c658483c091 --- /dev/null +++ b/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml @@ -0,0 +1,49 @@ + + + + 4.1.0 + + org.apache.maven.tests + sources-mixed-modules-test + 1.0-SNAPSHOT + jar + + + + src/should-be-ignored/java + + + + + main + java + org.foo.moduleA + + + + main + java + + + + + test + java + org.foo.moduleA + + + +