SourceHandlingContext.java

/*
 * 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.
 * <p>
 * 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.
 * <p>
 * Key features:
 * <ul>
 *   <li>Tracks declared sources using {@code (language, scope, module, directory)} identity</li>
 *   <li>Only tracks enabled sources - disabled sources are effectively no-ops</li>
 *   <li>Detects duplicate enabled sources and emits warnings</li>
 *   <li>Provides {@link #hasSources(Language, ProjectScope)} to check if sources exist for a combination</li>
 * </ul>
 *
 * @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<String> modules;
    private final boolean modularProject;
    private final ModelBuilderResult result;
    private final Set<SourceKey> declaredSources;

    SourceHandlingContext(
            MavenProject project,
            Path baseDir,
            Set<String> 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.
     * <p>
     * Rules:
     * <ul>
     *   <li>Disabled sources are always added (they're filtered by {@code getEnabledSourceRoots()})</li>
     *   <li>First enabled source for an identity is added and tracked</li>
     *   <li>Subsequent enabled sources with same identity trigger a WARNING and are NOT added</li>
     * </ul>
     *
     * @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.
     * <p>
     * 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.
     * <p>
     * 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:
     * <ol>
     *   <li>Modular project: use resources from {@code <sources>} if present, otherwise inject defaults</li>
     *   <li>Classic project: use resources from {@code <sources>} if present, otherwise use legacy resources</li>
     * </ol>
     *
     * @param scope the project scope (MAIN or TEST)
     */
    void handleResourceConfiguration(ProjectScope scope) {
        boolean hasResourcesInSources = hasSources(Language.RESOURCES, scope);

        List<Resource> 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 ? "<resources>" : "<testResources>";
        String sourcesConfig = scope == ProjectScope.MAIN
                ? "<source><lang>resources</lang></source>"
                : "<source><lang>resources</lang><scope>test</scope></source>";

        if (modularProject) {
            if (hasResourcesInSources) {
                // Modular project with resources configured via <sources> - already added above
                if (hasExplicitLegacyResources(resources, scopeId)) {
                    LOGGER.warn(
                            "Legacy {} element is ignored because {} resources are configured via {} in <sources>.",
                            legacyElement,
                            scopeId,
                            sourcesConfig);
                } else {
                    LOGGER.debug(
                            "{} resources configured via <sources> element, ignoring legacy {} element.",
                            scopeName,
                            legacyElement);
                }
            } else {
                // Modular project without resources in <sources> - inject module-aware defaults
                if (hasExplicitLegacyResources(resources, scopeId)) {
                    String message = "Legacy " + legacyElement
                            + " element is ignored because modular sources are configured. "
                            + "Use " + sourcesConfig + " in <sources> 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 <sources> - already added above
                if (hasExplicitLegacyResources(resources, scopeId)) {
                    LOGGER.warn(
                            "Legacy {} element is ignored because {} resources are configured via {} in <sources>.",
                            legacyElement,
                            scopeId,
                            sourcesConfig);
                } else {
                    LOGGER.debug(
                            "{} resources configured via <sources> 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/<module>/<scope>/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/<module>
                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<Resource> 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;
    }
}