/**
 * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
 */

package net.sourceforge.pmd;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;

import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.LoggerFactory;

import net.sourceforge.pmd.annotation.DeprecatedUntil700;
import net.sourceforge.pmd.cache.AnalysisCache;
import net.sourceforge.pmd.cache.FileAnalysisCache;
import net.sourceforge.pmd.cache.NoopAnalysisCache;
import net.sourceforge.pmd.internal.util.ClasspathClassLoader;
import net.sourceforge.pmd.lang.Language;
import net.sourceforge.pmd.lang.LanguagePropertyBundle;
import net.sourceforge.pmd.lang.LanguageRegistry;
import net.sourceforge.pmd.lang.LanguageVersion;
import net.sourceforge.pmd.lang.LanguageVersionDiscoverer;
import net.sourceforge.pmd.renderers.Renderer;
import net.sourceforge.pmd.renderers.RendererFactory;
import net.sourceforge.pmd.util.AssertionUtil;
import net.sourceforge.pmd.util.log.MessageReporter;
import net.sourceforge.pmd.util.log.internal.SimpleMessageReporter;

/**
 * This class contains the details for the runtime configuration of a
 * PMD run. Once configured, use {@link PmdAnalysis#create(PMDConfiguration)}
 * in a try-with-resources to execute the analysis (see {@link PmdAnalysis}).
 *
 * <h3>Rulesets</h3>
 *
 * <ul>
 * <li>You can configure paths to the rulesets to use with {@link #addRuleSet(String)}.
 * These can be file paths or classpath resources.</li>
 * <li>Use {@link #setMinimumPriority(RulePriority)} to control the minimum priority a
 * rule must have to be included. Defaults to the lowest priority, ie all rules are loaded.</li>
 * <li>Use {@link #setRuleSetFactoryCompatibilityEnabled(boolean)} to disable the
 * compatibility measures for removed and renamed rules in the rulesets that will
 * be loaded.
 * </ul>
 *
 * <h3>Source files</h3>
 *
 * <ul>
 * <li>The default encoding of source files is the system default as
 * returned by <code>System.getProperty("file.encoding")</code>.
 * You can set it with {@link #setSourceEncoding(String)}.</li>
 * <li>The source files to analyze can be given in many ways. See
 * {@link #addInputPath(Path)} {@link #setInputFilePath(Path)}, {@link #setInputUri(URI)}.
 * <li>Files are assigned a language based on their name. The language
 * version of languages can be given with
 * {@link #setDefaultLanguageVersion(LanguageVersion)}.
 * The default language assignment can be overridden with
 * {@link #setForceLanguageVersion(LanguageVersion)}.</li>
 * </ul>
 *
 * <h3>Rendering</h3>
 *
 * <ul>
 * <li>The renderer format to use for Reports. {@link #getReportFormat()}</li>
 * <li>The file to which the Report should render. {@link #getReportFile()}</li>
 * <li>Configure the root paths that are used to relativize file names in reports via {@link #addRelativizeRoot(Path)}.
 * This enables to get short names in reports.</li>
 * <li>The initialization properties to use when creating a Renderer instance.
 * {@link #getReportProperties()}</li>
 * <li>An indicator of whether to show suppressed Rule violations in Reports.
 * {@link #isShowSuppressedViolations()}</li>
 * </ul>
 *
 * <h3>Language configuration </h3>
 * <ul>
 * <li>Use {@link #setSuppressMarker(String)} to change the comment marker for suppression comments. Defaults to {@value #DEFAULT_SUPPRESS_MARKER}.</li>
 * <li>See {@link #setClassLoader(ClassLoader)} and {@link #prependAuxClasspath(String)} for
 *  information for how to configure classpath for Java analysis.</li>
 * <li>You can set additional language properties with {@link #getLanguageProperties(Language)}</li>
 * </ul>
 *
 * <h3>Miscellaneous</h3>
 * <ul>
 * <li>Use {@link #setThreads(int)} to control the parallelism of the analysis. Defaults
 * one thread per available processor. {@link #getThreads()}</li>
 * </ul>
 */
public class PMDConfiguration extends AbstractConfiguration {
    private static final LanguageRegistry DEFAULT_REGISTRY = LanguageRegistry.PMD;

    /** The default suppress marker string. */
    public static final String DEFAULT_SUPPRESS_MARKER = "NOPMD";

    // General behavior options
    private String suppressMarker = DEFAULT_SUPPRESS_MARKER;
    private int threads = Runtime.getRuntime().availableProcessors();
    private ClassLoader classLoader = getClass().getClassLoader();
    private final LanguageVersionDiscoverer languageVersionDiscoverer;
    private LanguageVersion forceLanguageVersion;
    private MessageReporter reporter = new SimpleMessageReporter(LoggerFactory.getLogger(PmdAnalysis.class));

    // Rule and source file options
    private List<String> ruleSets = new ArrayList<>();
    private RulePriority minimumPriority = RulePriority.LOW;
    private @NonNull List<Path> inputPaths = new ArrayList<>();
    private URI inputUri;
    private Path inputFilePath;
    private Path ignoreFilePath;
    private boolean ruleSetFactoryCompatibilityEnabled = true;

    // Reporting options
    private String reportFormat;
    private Path reportFile;
    private Properties reportProperties = new Properties();
    private boolean showSuppressedViolations = false;
    private boolean failOnViolation = true;

    private AnalysisCache analysisCache = new NoopAnalysisCache();
    private boolean ignoreIncrementalAnalysis;
    private final LanguageRegistry langRegistry;
    private final List<Path> relativizeRoots = new ArrayList<>();
    private final Map<Language, LanguagePropertyBundle> langProperties = new HashMap<>();

    public PMDConfiguration() {
        this(DEFAULT_REGISTRY);
    }

    public PMDConfiguration(@NonNull LanguageRegistry languageRegistry) {
        this.langRegistry = Objects.requireNonNull(languageRegistry);
        this.languageVersionDiscoverer = new LanguageVersionDiscoverer(languageRegistry);
    }

    /**
     * Get the suppress marker. This is the source level marker used to indicate
     * a RuleViolation should be suppressed.
     *
     * @return The suppress marker.
     */
    public String getSuppressMarker() {
        return suppressMarker;
    }

    /**
     * Set the suppress marker.
     *
     * @param suppressMarker
     *            The suppress marker to use.
     */
    public void setSuppressMarker(String suppressMarker) {
        Objects.requireNonNull(suppressMarker, "Suppress marker was null");
        this.suppressMarker = suppressMarker;
    }

    /**
     * Get the number of threads to use when processing Rules.
     *
     * @return The number of threads.
     */
    public int getThreads() {
        return threads;
    }

    /**
     * Set the number of threads to use when processing Rules.
     *
     * @param threads
     *            The number of threads.
     */
    public void setThreads(int threads) {
        this.threads = threads;
    }

    /**
     * Get the ClassLoader being used by PMD when processing Rules.
     *
     * @return The ClassLoader being used
     */
    public ClassLoader getClassLoader() {
        return classLoader;
    }

    /**
     * Set the ClassLoader being used by PMD when processing Rules. Setting a
     * value of <code>null</code> will cause the default ClassLoader to be used.
     *
     * @param classLoader
     *            The ClassLoader to use
     */
    public void setClassLoader(ClassLoader classLoader) {
        if (classLoader == null) {
            this.classLoader = getClass().getClassLoader();
        } else {
            this.classLoader = classLoader;
        }
    }

    /**
     * Prepend the specified classpath like string to the current ClassLoader of
     * the configuration. If no ClassLoader is currently configured, the
     * ClassLoader used to load the {@link PMDConfiguration} class will be used
     * as the parent ClassLoader of the created ClassLoader.
     *
     * <p>If the classpath String looks like a URL to a file (i.e. starts with
     * <code>file://</code>) the file will be read with each line representing
     * an entry on the classpath.</p>
     *
     * @param classpath
     *            The prepended classpath.
     * @throws IOException
     *             if the given classpath is invalid (e.g. does not exist)
     * @see PMDConfiguration#setClassLoader(ClassLoader)
     * @see ClasspathClassLoader
     *
     * @deprecated Use {@link #prependAuxClasspath(String)}, which doesn't
     * throw a checked {@link IOException}
     */
    @Deprecated
    public void prependClasspath(String classpath) throws IOException {
        try {
            prependAuxClasspath(classpath);
        } catch (IllegalArgumentException e) {
            throw new IOException(e);
        }
    }

    /**
     * Prepend the specified classpath like string to the current ClassLoader of
     * the configuration. If no ClassLoader is currently configured, the
     * ClassLoader used to load the {@link PMDConfiguration} class will be used
     * as the parent ClassLoader of the created ClassLoader.
     *
     * <p>If the classpath String looks like a URL to a file (i.e. starts with
     * <code>file://</code>) the file will be read with each line representing
     * an entry on the classpath.</p>
     *
     * <p>You can specify multiple class paths separated by `:` on Unix-systems or `;` under Windows.
     * See {@link File#pathSeparator}.
     *
     * @param classpath The prepended classpath.
     *
     * @throws IllegalArgumentException if the given classpath is invalid (e.g. does not exist)
     * @see PMDConfiguration#setClassLoader(ClassLoader)
     */
    public void prependAuxClasspath(String classpath) {
        try {
            if (classLoader == null) {
                classLoader = PMDConfiguration.class.getClassLoader();
            }
            if (classpath != null) {
                classLoader = new ClasspathClassLoader(classpath, classLoader);
            }
        } catch (IOException e) {
            // Note: IOExceptions shouldn't appear anymore, they should already be converted
            // to IllegalArgumentException in ClasspathClassLoader.
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Returns the message reporter that is to be used while running
     * the analysis.
     */
    public @NonNull MessageReporter getReporter() {
        return reporter;
    }

    /**
     * Sets the message reporter that is to be used while running
     * the analysis.
     *
     * @param reporter A non-null message reporter
     */
    public void setReporter(@NonNull MessageReporter reporter) {
        AssertionUtil.requireParamNotNull("reporter", reporter);
        this.reporter = reporter;
    }

    /**
     * Get the LanguageVersionDiscoverer, used to determine the LanguageVersion
     * of a source file.
     *
     * @return The LanguageVersionDiscoverer.
     */
    public LanguageVersionDiscoverer getLanguageVersionDiscoverer() {
        return languageVersionDiscoverer;
    }

    /**
     * Get the LanguageVersion specified by the force-language parameter. This overrides detection based on file
     * extensions
     *
     * @return The LanguageVersion.
     */
    public LanguageVersion getForceLanguageVersion() {
        return forceLanguageVersion;
    }

    /**
     * Is the force-language parameter set to anything?
     *
     * @return true if ${@link #getForceLanguageVersion()} is not null
     */
    public boolean isForceLanguageVersion() {
        return forceLanguageVersion != null;
    }

    /**
     * Set the LanguageVersion specified by the force-language parameter. This overrides detection based on file
     * extensions
     *
     * @param forceLanguageVersion the language version
     */
    public void setForceLanguageVersion(@Nullable LanguageVersion forceLanguageVersion) {
        if (forceLanguageVersion != null) {
            checkLanguageIsRegistered(forceLanguageVersion.getLanguage());
        }
        this.forceLanguageVersion = forceLanguageVersion;
        languageVersionDiscoverer.setForcedVersion(forceLanguageVersion);
    }

    /**
     * Set the given LanguageVersion as the current default for it's Language.
     *
     * @param languageVersion
     *            the LanguageVersion
     */
    public void setDefaultLanguageVersion(LanguageVersion languageVersion) {
        Objects.requireNonNull(languageVersion);
        languageVersionDiscoverer.setDefaultLanguageVersion(languageVersion);
        getLanguageProperties(languageVersion.getLanguage()).setLanguageVersion(languageVersion.getVersion());
    }

    /**
     * Set the given LanguageVersions as the current default for their
     * Languages.
     *
     * @param languageVersions
     *            The LanguageVersions.
     */
    public void setDefaultLanguageVersions(List<LanguageVersion> languageVersions) {
        for (LanguageVersion languageVersion : languageVersions) {
            setDefaultLanguageVersion(languageVersion);
        }
    }

    /**
     * Get the LanguageVersion of the source file with given name. This depends
     * on the fileName extension, and the java version.
     * <p>
     * For compatibility with older code that does not always pass in a correct
     * filename, unrecognized files are assumed to be java files.
     * </p>
     *
     * @param fileName
     *            Name of the file, can be absolute, or simple.
     * @return the LanguageVersion
     */
    // FUTURE Delete this? I can't think of a good reason to keep it around.
    // Failure to determine the LanguageVersion for a file should be a hard
    // error, or simply cause the file to be skipped?
    public @Nullable LanguageVersion getLanguageVersionOfFile(String fileName) {
        LanguageVersion forcedVersion = getForceLanguageVersion();
        if (forcedVersion != null) {
            // use force language if given
            return forcedVersion;
        }

        // otherwise determine by file extension
        return languageVersionDiscoverer.getDefaultLanguageVersionForFile(fileName);
    }

    LanguageRegistry getLanguageRegistry() {
        return langRegistry;
    }

    /**
     * Get the comma separated list of RuleSet URIs.
     *
     * @return The RuleSet URIs.
     *
     * @deprecated Use {@link #getRuleSetPaths()}
     */
    @Deprecated
    @DeprecatedUntil700
    public @Nullable String getRuleSets() {
        if (ruleSets.isEmpty()) {
            return null;
        }
        return String.join(",", ruleSets);
    }

    /**
     * Returns the list of ruleset URIs.
     *
     * @see RuleSetLoader#loadFromResource(String)
     */
    public @NonNull List<@NonNull String> getRuleSetPaths() {
        return ruleSets;
    }

    /**
     * Sets the list of ruleset paths to load when starting the analysis.
     *
     * @param ruleSetPaths A list of ruleset paths, understandable by {@link RuleSetLoader#loadFromResource(String)}.
     *
     * @throws NullPointerException If the parameter is null
     */
    public void setRuleSets(@NonNull List<@NonNull String> ruleSetPaths) {
        AssertionUtil.requireParamNotNull("ruleSetPaths", ruleSetPaths);
        AssertionUtil.requireContainsNoNullValue("ruleSetPaths", ruleSetPaths);
        this.ruleSets = new ArrayList<>(ruleSetPaths);
    }

    /**
     * Add a new ruleset paths to load when starting the analysis.
     * This list is initially empty.
     *
     * @param rulesetPath A ruleset path, understandable by {@link RuleSetLoader#loadFromResource(String)}.
     *
     * @throws NullPointerException If the parameter is null
     */
    public void addRuleSet(@NonNull String rulesetPath) {
        AssertionUtil.requireParamNotNull("rulesetPath", rulesetPath);
        this.ruleSets.add(rulesetPath);
    }

    /**
     * Set the comma separated list of RuleSet URIs.
     *
     * @param ruleSets the rulesets to set
     *
     * @deprecated Use {@link #setRuleSets(List)} or {@link #addRuleSet(String)}.
     */
    @Deprecated
    @DeprecatedUntil700
    public void setRuleSets(@Nullable String ruleSets) {
        if (ruleSets == null) {
            this.ruleSets = new ArrayList<>();
        } else {
            this.ruleSets = new ArrayList<>(Arrays.asList(ruleSets.split(",")));
        }
    }

    /**
     * Get the minimum priority threshold when loading Rules from RuleSets.
     *
     * @return The minimum priority threshold.
     */
    public RulePriority getMinimumPriority() {
        return minimumPriority;
    }

    /**
     * Set the minimum priority threshold when loading Rules from RuleSets.
     *
     * @param minimumPriority
     *            The minimum priority.
     */
    public void setMinimumPriority(RulePriority minimumPriority) {
        this.minimumPriority = minimumPriority;
    }


    /**
     * Returns the list of input paths to explore. This is an
     * unmodifiable list.
     */
    public @NonNull List<Path> getInputPathList() {
        return Collections.unmodifiableList(inputPaths);
    }

    /**
     * Set the comma separated list of input paths to process for source files.
     *
     * @param inputPaths The comma separated list.
     *
     * @throws NullPointerException If the parameter is null
     * @deprecated Use {@link #setInputPathList(List)} or {@link #addInputPath(Path)}
     */
    @Deprecated
    public void setInputPaths(String inputPaths) {
        if (inputPaths.isEmpty()) {
            return;
        }
        List<Path> paths = new ArrayList<>();
        for (String s : inputPaths.split(",")) {
            paths.add(Paths.get(s));
        }
        this.inputPaths = paths;
    }

    /**
     * Set the input paths to the given list of paths.
     *
     * @throws NullPointerException If the parameter is null or contains a null value
     */
    public void setInputPathList(final List<Path> inputPaths) {
        AssertionUtil.requireContainsNoNullValue("input paths", inputPaths);
        this.inputPaths = new ArrayList<>(inputPaths);
    }


    /**
     * Add an input path. It is not split on commas.
     *
     * @throws NullPointerException If the parameter is null
     */
    public void addInputPath(@NonNull Path inputPath) {
        Objects.requireNonNull(inputPath);
        this.inputPaths.add(inputPath);
    }

    /** Returns the path to the file list text file. */
    public @Nullable Path getInputFile() {
        return inputFilePath;
    }

    public @Nullable Path getIgnoreFile() {
        return ignoreFilePath;
    }

    /**
     * The input file path points to a single file, which contains a
     * comma-separated list of source file names to process.
     *
     * @param inputFilePath path to the file
     * @deprecated Use {@link #setInputFilePath(Path)}
     */
    @Deprecated
    public void setInputFilePath(String inputFilePath) {
        this.inputFilePath = inputFilePath == null ? null : Paths.get(inputFilePath);
    }

    /**
     * The input file path points to a single file, which contains a
     * comma-separated list of source file names to process.
     *
     * @param inputFilePath path to the file
     */
    public void setInputFilePath(Path inputFilePath) {
        this.inputFilePath = inputFilePath;
    }

    /**
     * The input file path points to a single file, which contains a
     * comma-separated list of source file names to ignore.
     *
     * @param ignoreFilePath path to the file
     * @deprecated Use {@link #setIgnoreFilePath(Path)}
     */
    @Deprecated
    public void setIgnoreFilePath(String ignoreFilePath) {
        this.ignoreFilePath = ignoreFilePath == null ? null : Paths.get(ignoreFilePath);
    }

    /**
     * The input file path points to a single file, which contains a
     * comma-separated list of source file names to ignore.
     *
     * @param ignoreFilePath  path to the file
     */
    public void setIgnoreFilePath(Path ignoreFilePath) {
        this.ignoreFilePath = ignoreFilePath;
    }

    /**
     * Get the input URI to process for source code objects.
     *
     * @return URI
     */
    public URI getUri() {
        return inputUri;
    }

    /**
     * Set the input URI to process for source code objects.
     *
     * @param inputUri a single URI
     * @deprecated Use {@link PMDConfiguration#setInputUri(URI)}
     */
    @Deprecated
    public void setInputUri(String inputUri) {
        this.inputUri = inputUri == null ? null : URI.create(inputUri);
    }

    /**
     * Set the input URI to process for source code objects.
     *
     * @param inputUri a single URI
     */
    public void setInputUri(URI inputUri) {
        this.inputUri = inputUri;
    }

    /**
     * Create a Renderer instance based upon the configured reporting options.
     * No writer is created.
     *
     * @return renderer
     */
    public Renderer createRenderer() {
        return createRenderer(false);
    }

    /**
     * Create a Renderer instance based upon the configured reporting options.
     * If withReportWriter then we'll configure it with a writer for the
     * reportFile specified.
     *
     * @param withReportWriter
     *            whether to configure a writer or not
     * @return A Renderer instance.
     */
    public Renderer createRenderer(boolean withReportWriter) {
        Renderer renderer = RendererFactory.createRenderer(reportFormat, reportProperties);
        renderer.setShowSuppressedViolations(showSuppressedViolations);
        if (withReportWriter) {
            renderer.setReportFile(reportFile == null ? null : reportFile.toString());
        }
        return renderer;
    }

    /**
     * Get the report format.
     *
     * @return The report format.
     */
    public String getReportFormat() {
        return reportFormat;
    }

    /**
     * Set the report format. This should be a name of a Renderer.
     *
     * @param reportFormat
     *            The report format.
     *
     * @see Renderer
     */
    public void setReportFormat(String reportFormat) {
        this.reportFormat = reportFormat;
    }

    /**
     * Get the file to which the report should render.
     *
     * @return The file to which to render.
     * @deprecated Use {@link #getReportFilePath()}
     */
    @Deprecated
    public String getReportFile() {
        return reportFile == null ? null : reportFile.toString();
    }

    /**
     * Get the file to which the report should render.
     *
     * @return The file to which to render.
     */
    public Path getReportFilePath() {
        return reportFile;
    }

    /**
     * Set the file to which the report should render.
     *
     * @param reportFile the file to set
     * @deprecated Use {@link #setReportFile(Path)}
     */
    @Deprecated
    public void setReportFile(String reportFile) {
        this.reportFile = reportFile == null ? null : Paths.get(reportFile);
    }

    /**
     * Set the file to which the report should render.
     *
     * @param reportFile the file to set
     */
    public void setReportFile(Path reportFile) {
        this.reportFile = reportFile;
    }

    /**
     * Get whether the report should show suppressed violations.
     *
     * @return <code>true</code> if showing suppressed violations,
     *         <code>false</code> otherwise.
     */
    public boolean isShowSuppressedViolations() {
        return showSuppressedViolations;
    }

    /**
     * Set whether the report should show suppressed violations.
     *
     * @param showSuppressedViolations
     *            <code>true</code> if showing suppressed violations,
     *            <code>false</code> otherwise.
     */
    public void setShowSuppressedViolations(boolean showSuppressedViolations) {
        this.showSuppressedViolations = showSuppressedViolations;
    }

    /**
     * Get the Report properties. These are used to create the Renderer.
     *
     * @return The report properties.
     */
    public Properties getReportProperties() {
        return reportProperties;
    }

    /**
     * Set the Report properties. These are used to create the Renderer.
     *
     * @param reportProperties
     *            The Report properties to set.
     */
    public void setReportProperties(Properties reportProperties) {
        this.reportProperties = reportProperties;
    }

    /**
     * Whether PMD should exit with status 4 (the default behavior, true) if
     * violations are found or just with 0 (to not break the build, e.g.).
     *
     * @return failOnViolation
     */
    public boolean isFailOnViolation() {
        return failOnViolation;
    }

    /**
     * Sets whether PMD should exit with status 4 (the default behavior, true)
     * if violations are found or just with 0 (to not break the build, e.g.).
     *
     * @param failOnViolation
     *            failOnViolation
     */
    public void setFailOnViolation(boolean failOnViolation) {
        this.failOnViolation = failOnViolation;
    }

    /**
     * Checks if the rule set factory compatibility feature is enabled.
     *
     * @return true, if the rule set factory compatibility feature is enabled
     *
     * @see RuleSetLoader#enableCompatibility(boolean)
     */
    public boolean isRuleSetFactoryCompatibilityEnabled() {
        return ruleSetFactoryCompatibilityEnabled;
    }

    /**
     * Sets the rule set factory compatibility feature enabled/disabled.
     *
     * @param ruleSetFactoryCompatibilityEnabled {@code true} if the feature should be enabled
     *
     * @see RuleSetLoader#enableCompatibility(boolean)
     */
    public void setRuleSetFactoryCompatibilityEnabled(boolean ruleSetFactoryCompatibilityEnabled) {
        this.ruleSetFactoryCompatibilityEnabled = ruleSetFactoryCompatibilityEnabled;
    }

    /**
     * Retrieves the currently used analysis cache. Will never be null.
     *
     * @return The currently used analysis cache. Never null.
     */
    public AnalysisCache getAnalysisCache() {
        // Make sure we are not null
        if (analysisCache == null || isIgnoreIncrementalAnalysis() && !(analysisCache instanceof NoopAnalysisCache)) {
            // sets a noop cache
            setAnalysisCache(new NoopAnalysisCache());
        }

        return analysisCache;
    }

    /**
     * Sets the analysis cache to be used. Setting a
     * value of {@code null} will cause a Noop AnalysisCache to be used.
     * If incremental analysis was explicitly disabled ({@link #isIgnoreIncrementalAnalysis()}),
     * then this method is a noop.
     *
     * @param cache The analysis cache to be used.
     */
    public void setAnalysisCache(final AnalysisCache cache) {
        // the doc says it's a noop if incremental analysis was disabled,
        // but it's actually the getter that enforces that
        this.analysisCache = cache == null ? new NoopAnalysisCache() : cache;
    }

    /**
     * Sets the location of the analysis cache to be used. This will automatically configure
     * and appropriate AnalysisCache implementation.
     *
     * @param cacheLocation The location of the analysis cache to be used.
     */
    public void setAnalysisCacheLocation(final String cacheLocation) {
        setAnalysisCache(cacheLocation == null
                         ? new NoopAnalysisCache()
                         : new FileAnalysisCache(new File(cacheLocation)));
    }


    /**
     * Sets whether the user has explicitly disabled incremental analysis or not.
     * If so, incremental analysis is not used, and all suggestions to use it are
     * disabled. The analysis cached location is ignored, even if it's specified.
     *
     * @param noCache Whether to ignore incremental analysis or not
     */
    public void setIgnoreIncrementalAnalysis(boolean noCache) {
        // see #getAnalysisCache for the implementation.
        this.ignoreIncrementalAnalysis = noCache;
    }


    /**
     * Returns whether incremental analysis was explicitly disabled by the user
     * or not.
     *
     * @return {@code true} if incremental analysis is explicitly disabled
     */
    public boolean isIgnoreIncrementalAnalysis() {
        return ignoreIncrementalAnalysis;
    }

    /**
     * Set the path used to shorten paths output in the report.
     * The path does not need to exist. If it exists, it must point
     * to a directory and not a file. See {@link #getRelativizeRoots()}
     * for the interpretation.
     *
     * <p>If several paths are added, the shortest paths possible are
     * built.
     *
     * @param path A path
     *
     * @throws IllegalArgumentException If the path points to a file, and not a directory
     * @throws NullPointerException If the path is null
     */
    public void addRelativizeRoot(Path path) {
        // Note: the given path is not further modified or resolved. E.g. there is no special handling for symlinks.
        // The goal is, that if the user inputs a path, PMD should output in terms of that path, not it's resolution.
        this.relativizeRoots.add(Objects.requireNonNull(path));

        if (Files.isRegularFile(path)) {
            throw new IllegalArgumentException("Relativize root should be a directory: " + path);
        }
    }


    /**
     * Add several paths to shorten paths that are output in the report.
     * See {@link #addRelativizeRoot(Path)}.
     *
     * @param paths A list of non-null paths
     *
     * @throws IllegalArgumentException If any path points to a file, and not a directory
     * @throws NullPointerException     If the list, or any path in the list is null
     */
    public void addRelativizeRoots(List<Path> paths) {
        for (Path path : paths) {
            addRelativizeRoot(path);
        }
    }

    /**
     * Returns the paths used to shorten paths output in the report.
     * <ul>
     * <li>If the list is empty, then paths are not touched
     * <li>If the list is non-empty, then source file paths are relativized with all the items in the list.
     * The shortest of these relative paths is taken as the display name of the file.
     * </ul>
     */
    public List<Path> getRelativizeRoots() {
        return Collections.unmodifiableList(relativizeRoots);
    }

    /**
     * Returns a mutable bundle of language properties that are associated
     * to the given language (always the same for a given language).
     *
     * @param language A language, which must be registered
     */
    public @NonNull LanguagePropertyBundle getLanguageProperties(Language language) {
        checkLanguageIsRegistered(language);
        return langProperties.computeIfAbsent(language, Language::newPropertyBundle);
    }

    void checkLanguageIsRegistered(Language language) {
        if (!langRegistry.getLanguages().contains(language)) {
            throw new IllegalArgumentException(
                "Language '" + language.getId() + "' is not registered in " + getLanguageRegistry());
        }
    }
}
