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 99289fc7aff6..b1a96ef81d5e 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; @@ -525,7 +526,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); } @@ -571,7 +572,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()) { @@ -665,65 +666,75 @@ private void initProject(MavenProject project, ModelBuilderResult result) { return build.getDirectory(); } }; - boolean hasScript = false; - boolean hasMain = false; - boolean hasTest = false; - boolean hasMainResources = false; - boolean hasTestResources = 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 if (Language.RESOURCES.equals(language)) { - ProjectScope scope = src.scope(); - if (ProjectScope.MAIN.equals(scope)) { - hasMainResources = true; - } else if (ProjectScope.TEST.equals(scope)) { - hasTestResources = true; - } - } 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()); + 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()); + } } - // 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); + // Validate that modular and classic sources are not mixed within + sourceContext.validateNoMixedModularAndClassicSources(); - // Handle main and test resources - ResourceHandlingContext resourceContext = - new ResourceHandlingContext(project, baseDir, modules, isModularProject, result); - resourceContext.handleResourceConfiguration(ProjectScope.MAIN, hasMainResources); - resourceContext.handleResourceConfiguration(ProjectScope.TEST, hasTestResources); + // Handle main and test resources using unified source handling + sourceContext.handleResourceConfiguration(ProjectScope.MAIN); + sourceContext.handleResourceConfiguration(ProjectScope.TEST); } project.setActiveProfiles( @@ -894,6 +905,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 attribute 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(); @@ -1036,7 +1090,7 @@ private DependencyResolutionResult resolveDependencies(MavenProject project) { } private List getProfileIds(List profiles) { - return profiles.stream().map(Profile::getId).collect(Collectors.toList()); + return profiles.stream().map(Profile::getId).toList(); } private static ModelSource createStubModelSource(Artifact artifact) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/ResourceHandlingContext.java b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java similarity index 54% rename from impl/maven-core/src/main/java/org/apache/maven/project/ResourceHandlingContext.java rename to impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java index 48fc9e7e03c9..e7691eb86bcf 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/ResourceHandlingContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java @@ -19,34 +19,55 @@ 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 resource configuration for Maven projects. - * Groups parameters shared between main and test resource handling. + * 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 ResourceHandlingContext { +class SourceHandlingContext { - private static final Logger LOGGER = LoggerFactory.getLogger(ResourceHandlingContext.class); + 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; - ResourceHandlingContext( + SourceHandlingContext( MavenProject project, Path baseDir, Set modules, @@ -57,6 +78,119 @@ class ResourceHandlingContext { 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)); + } + } + } } /** @@ -68,9 +202,10 @@ class ResourceHandlingContext { * * * @param scope the project scope (MAIN or TEST) - * @param hasResourcesInSources whether resources are configured via {@code } */ - void handleResourceConfiguration(ProjectScope scope, boolean hasResourcesInSources) { + void handleResourceConfiguration(ProjectScope scope) { + boolean hasResourcesInSources = hasSources(Language.RESOURCES, scope); + List resources = scope == ProjectScope.MAIN ? project.getBuild().getDelegate().getResources() : project.getBuild().getDelegate().getTestResources(); @@ -105,7 +240,7 @@ void handleResourceConfiguration(ProjectScope scope, boolean hasResourcesInSourc + "Use " + sourcesConfig + " in for custom resource paths."; LOGGER.warn(message); result.getProblemCollector() - .reportProblem(new org.apache.maven.impl.model.DefaultModelProblem( + .reportProblem(new DefaultModelProblem( message, Severity.WARNING, Version.V41, 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 a8825bc5245b..8c25ccc88ad8 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 @@ -379,6 +379,10 @@ void testLocationTrackingResolution() throws Exception { /** * 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 { @@ -389,7 +393,7 @@ void testModularSourcesInjectResourceRoots() throws Exception { // Get all resource source roots for main scope List mainResourceRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES) - .collect(Collectors.toList()); + .toList(); // Should have resource roots for both modules Set modules = mainResourceRoots.stream() @@ -404,7 +408,7 @@ void testModularSourcesInjectResourceRoots() throws Exception { // Get all resource source roots for test scope List testResourceRoots = project.getEnabledSourceRoots(ProjectScope.TEST, Language.RESOURCES) - .collect(Collectors.toList()); + .toList(); // Should have test resource roots for both modules Set testModules = testResourceRoots.stream() @@ -421,10 +425,14 @@ void testModularSourcesInjectResourceRoots() throws Exception { /** * 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 { @@ -444,7 +452,7 @@ void testModularSourcesWithExplicitResourcesIssuesWarning() throws Exception { List warnings = result.getProblems().stream() .filter(p -> p.getSeverity() == ModelProblem.Severity.WARNING) .filter(p -> p.getMessage().contains("Legacy") && p.getMessage().contains("ignored")) - .collect(Collectors.toList()); + .toList(); assertEquals(2, warnings.size(), "Should have 2 warnings (one for resources, one for testResources)"); assertTrue( @@ -456,7 +464,7 @@ void testModularSourcesWithExplicitResourcesIssuesWarning() throws Exception { // Verify modular resources are still injected correctly List mainResourceRoots = project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES) - .collect(Collectors.toList()); + .toList(); assertEquals(2, mainResourceRoots.size(), "Should have 2 modular resource roots (one per module)"); @@ -470,4 +478,257 @@ void testModularSourcesWithExplicitResourcesIssuesWarning() throws Exception { 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) + .filter(opt -> opt.isPresent()) + .map(opt -> opt.get()) + .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('\\', '/').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 -> sr.module().isPresent() + && "com.example.app".equals(sr.module().get())) + .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('\\', '/').contains("src/com.example.app/main/java")); + boolean hasGeneratedDir = mainJavaRoots.stream().anyMatch(sr -> sr.directory() + .toString() + .replace('\\', '/') + .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 -> sr.module().isPresent() + && "com.example.app".equals(sr.module().get())) + .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 -> sr.module().isPresent() + && "com.example.other".equals(sr.module().get())); + 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 -> sr.module().isPresent() + && "com.example.dup".equals(sr.module().get())); + 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 + assertTrue( + testJavaRoots.get(0).module().isPresent() + && "com.example.dup" + .equals(testJavaRoots.get(0).module().get()), + "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/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 + + + +