ExclusionUtils.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.rat.config.exclusion;

import java.io.File;
import java.io.FileFilter;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;

import org.apache.commons.io.IOUtils;
import org.apache.commons.io.LineIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.rat.ConfigurationException;
import org.apache.rat.api.EnvVar;
import org.apache.rat.config.exclusion.plexus.MatchPattern;
import org.apache.rat.config.exclusion.plexus.SelectorUtils;
import org.apache.rat.document.DocumentName;
import org.apache.rat.document.DocumentNameMatcher;
import org.apache.rat.utils.ExtendedIterator;

import static java.lang.String.format;

/**
 * Utilities for exclusion processing.
 */
public final class ExclusionUtils {

    /** The list of comment prefixes that are used to filter comment lines.  */
    public static final List<String> COMMENT_PREFIXES = Arrays.asList("#", "##", "//", "/**", "/*");

    /** Prefix used to negate a given pattern. */
    public static final String NEGATION_PREFIX = "!";

    /** A predicate that filters out lines that do NOT start with {@link #NEGATION_PREFIX}. */
    public static final Predicate<String> NOT_MATCH_FILTER = s -> s.startsWith(NEGATION_PREFIX);

    /** A predicate that filters out lines that start with {@link #NEGATION_PREFIX}. */
    public static final Predicate<String> MATCH_FILTER = NOT_MATCH_FILTER.negate();

    private ExclusionUtils() {
        // do not instantiate
    }

    /**
     * Creates predicate that filters out comment and blank lines. Leading spaces are removed and
     * if the line then starts with a commentPrefix string it is considered a comment and will be removed
     *
     * @param commentPrefixes the list of comment prefixes.
     * @return the Predicate that returns false for lines that start with commentPrefixes or are empty.
     */
    public static Predicate<String> commentFilter(final Iterable<String> commentPrefixes) {
        return s -> {
            if (StringUtils.isNotBlank(s)) {
                int i = 1;
                while (StringUtils.isBlank(s.substring(0, i))) {
                    i++;
                }
                String trimmed = i > 0 ? s.substring(i - 1) : s;
                for (String prefix : commentPrefixes) {
                    if (trimmed.startsWith(prefix)) {
                        return false;
                    }
                }
                return true;
            }
            return false;
        };
    }

    /**
     * Creates predicate that filters out comment and blank lines. Leading spaces are removed and
     * if the line then starts with a commentPrefix string it is considered a comment and will be removed
     *
     * @param commentPrefix the prefix string for comments.
     * @return the Predicate that returns false for lines that start with commentPrefixes or are empty.
     */
    public static Predicate<String> commentFilter(final String commentPrefix) {
        return s -> {
            if (StringUtils.isNotBlank(s)) {
                int i = 1;
                while (StringUtils.isBlank(s.substring(0, i))) {
                    i++;
                }
                String trimmed = i > 0 ? s.substring(i - 1) : s;
                return !trimmed.startsWith(commentPrefix);
            }
            return false;
        };
    }

    /**
     * Create a FileFilter from a PathMatcher.
     * @param parent the document name for the parent of the file to be filtered.
     * @param nameMatcher the path matcher to convert.
     * @return a FileFilter.
     */
    public static FileFilter asFileFilter(final DocumentName parent, final DocumentNameMatcher nameMatcher) {
        return file -> {
            DocumentName candidate = DocumentName.builder(file).setBaseName(parent.getBaseName()).build();
            return EnvVar.RAT_DECOMPOSE_MATCHER_ON_USE.isSet() ? nameMatcher.logDecompositionWhileMatching(candidate) :
                nameMatcher.matches(candidate);
        };
    }

    /**
     * Creates an iterator of Strings from a file of patterns.
     * Removes comment lines.
     * @param patternFile the file to read.
     * @param commentFilters A predicate returning {@code true} for non-comment lines.
     * @return the iterable of Strings from the file.
     */
    public static ExtendedIterator<String> asIterator(final File patternFile, final Predicate<String> commentFilters) {
        verifyFile(patternFile);
        Objects.requireNonNull(commentFilters, "commentFilters");
        try {
            return ExtendedIterator.create(IOUtils.lineIterator(new FileReader(patternFile, StandardCharsets.UTF_8))).filter(commentFilters);
        } catch (IOException e) {
            throw new ConfigurationException(format("%s is not a valid file.", patternFile), e);
        }
    }

    /**
     * Creates an iterable of Strings from a file of patterns.
     * Removes comment lines.
     * @param patternFile the file to read.
     * @param commentPrefix the prefix string for comments.
     * @return the iterable of Strings from the file.
     */
    public static Iterable<String> asIterable(final File patternFile, final String commentPrefix)  {
        return asIterable(patternFile, commentFilter(commentPrefix));
    }

    /**
     * Creates an iterable of Strings from a file of patterns.
     * Removes comment lines.
     * @param patternFile the file to read.
     * @param commentFilters A predicate returning {@code true} for non-comment lines.
     * @return the iterable of Strings from the file.
     */
    public static Iterable<String> asIterable(final File patternFile, final Predicate<String> commentFilters)  {
        verifyFile(patternFile);
        Objects.requireNonNull(commentFilters, "commentFilters");
        // can not return LineIterator directly as the patternFile will not be closed leading
        // to a resource leak in some cases.
        try (FileReader reader = new FileReader(patternFile, StandardCharsets.UTF_8)) {
            List<String> result = new ArrayList<>();
            Iterator<String> iter = new LineIterator(reader) {
                @Override
                protected boolean isValidLine(final String line) {
                    return commentFilters.test(line);
                }
            };
            iter.forEachRemaining(result::add);
            return result;
        } catch (IOException e) {
            throw new ConfigurationException("Unable to read file " + patternFile, e);
        }
    }

    /**
     * Returns {@code true} if the filename represents a hidden file
     * @param fileName the file to check.
     * @return true if it is the name of a hidden file.
     */
    public static boolean isHidden(final String fileName) {
        return fileName.startsWith(".") && !(fileName.equals(".") || fileName.equals(".."));
    }

    private static void verifyFile(final File file) {
        if (file == null || !file.exists() || !file.isFile()) {
            throw new ConfigurationException(format("%s is not a valid file.", file));
        }
    }

    /**
     * Modifies the {@link MatchPattern} formatted {@code pattern} argument by expanding the pattern and
     * by adjusting the pattern to include the basename from the {@code documentName} argument.
     * @param documentName the name of the file being read.
     * @param pattern the pattern to format.
     * @return the completely formatted pattern
     */
    public static String qualifyPattern(final DocumentName documentName, final String pattern) {
        boolean prefix = pattern.startsWith(NEGATION_PREFIX);
        String workingPattern = prefix ? pattern.substring(1) : pattern;
        String normalizedPattern = SelectorUtils.extractPattern(workingPattern, documentName.getDirectorySeparator());

        StringBuilder sb = new StringBuilder(prefix ? NEGATION_PREFIX : "");
        if (SelectorUtils.isRegexPrefixedPattern(workingPattern)) {
            sb.append(SelectorUtils.REGEX_HANDLER_PREFIX)
                    .append("\\Q").append(documentName.getBaseName())
                    .append(documentName.getDirectorySeparator())
                    .append("\\E").append(normalizedPattern)
                    .append(SelectorUtils.PATTERN_HANDLER_SUFFIX);
        } else {
            sb.append(documentName.getBaseDocumentName().resolve(normalizedPattern).getName());
        }
        return sb.toString();
    }

    /**
     * Tokenizes the string based on the directory separator.
     * @param source the source to tokenize.
     * @param from the directory separator for the source.
     * @param to the directory separator for the result.
     * @return the source string with the separators converted.
     */
    public static String convertSeparator(final String source, final String from, final String to) {
        if (StringUtils.isEmpty(source) || from.equals(to)) {
            return source;
        }
        return String.join(to, source.split("\\Q" + from + "\\E"));
    }
}