GitIgnoreBuilder.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.fileProcessors;

import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;

import org.apache.commons.lang3.StringUtils;
import org.apache.rat.api.EnvVar;
import org.apache.rat.config.exclusion.ExclusionUtils;
import org.apache.rat.config.exclusion.MatcherSet;
import org.apache.rat.config.exclusion.plexus.MatchPatterns;
import org.apache.rat.document.DocumentName;
import org.apache.rat.document.DocumentNameMatcher;

import static org.apache.rat.config.exclusion.ExclusionUtils.NEGATION_PREFIX;

/**
 * Processes the {@code .gitignore} file.
 * @see <a href='https://git-scm.com/docs/gitignore'>.gitignore documentation</a>
 */
public class GitIgnoreBuilder extends AbstractFileProcessorBuilder {
    /** The name of the file we read from */
    private static final String IGNORE_FILE = ".gitignore";
    /** The comment prefix */
    private static final String COMMENT_PREFIX = "#";
    /** An escaped comment in the .gitignore file. (Not a comment) */
    private static final String ESCAPED_COMMENT = "\\#";
    /** An escaped negation in the .gitignore file. (Not a negation) */
    private static final String ESCAPED_NEGATION = "\\!";
    /** The slash string */
    private static final String SLASH = "/";

    /**
     * Constructs a file processor that processes a {@code .gitignore} file and ignores any lines starting with {@value #COMMENT_PREFIX}.
     */
    public GitIgnoreBuilder() {
        super(IGNORE_FILE, COMMENT_PREFIX, true);
    }

    private MatcherSet processGlobalIgnore(final Consumer<MatcherSet> matcherSetConsumer, final DocumentName root, final DocumentName globalGitIgnore) {
        final MatcherSet.Builder matcherSetBuilder = new MatcherSet.Builder();
        final List<String> iterable = new ArrayList<>();
        ExclusionUtils.asIterator(globalGitIgnore.asFile(), commentFilter)
                .map(entry -> modifyEntry(matcherSetConsumer, globalGitIgnore, entry).orElse(null))
                .filter(Objects::nonNull)
                .map(entry -> ExclusionUtils.qualifyPattern(root, entry))
                .forEachRemaining(iterable::add);

        Set<String> included = new HashSet<>();
        Set<String> excluded = new HashSet<>();
        MatcherSet.Builder.segregateList(excluded, included, iterable);
        DocumentName displayName = DocumentName.builder(root).setName("global gitignore").build();
        matcherSetBuilder.addExcluded(displayName, excluded);
        matcherSetBuilder.addIncluded(displayName, included);
        return matcherSetBuilder.build();
    }

    @Override
    protected MatcherSet process(final Consumer<MatcherSet> matcherSetConsumer, final DocumentName root, final DocumentName documentName) {
      if (root.equals(documentName.getBaseDocumentName())) {
          Optional<File> globalGitIgnore = globalGitIgnore();
          List<MatcherSet> matcherSets = new ArrayList<>();
          matcherSets.add(super.process(matcherSetConsumer, root, documentName));
          if (globalGitIgnore.isPresent()) {
              LevelBuilder levelBuilder = getLevelBuilder(Integer.MAX_VALUE);
              DocumentName ignore = DocumentName.builder(globalGitIgnore.get()).build();
              matcherSets.add(processGlobalIgnore(levelBuilder::add, root, ignore));
          }
          return MatcherSet.merge(matcherSets);
      } else {
          return super.process(matcherSetConsumer, root, documentName);
      }
    }

    /**
     * Convert the string entry.
     * If the string ends with a slash an {@link DocumentNameMatcher#and} is constructed from a directory check and the file
     * name matcher. In this case an empty Optional is returned.
     * If the string starts with {@value ExclusionUtils#NEGATION_PREFIX} then the entry is placed in the include list, otherwise
     * the entry is placed in the exclude list and the name of the check returned.
     * @param documentName The name of the document being processed.
     * @param entry The entry from the document
     * @return and Optional containing the name of the matcher.
     */
    @Override
    protected Optional<String> modifyEntry(final Consumer<MatcherSet> matcherSetConsumer, final DocumentName documentName, final String entry) {
        // An optional prefix "!" which negates the pattern;
        boolean prefix = entry.startsWith(NEGATION_PREFIX);
        String pattern = prefix || entry.startsWith(ESCAPED_COMMENT) || entry.startsWith(ESCAPED_NEGATION) ?
                entry.substring(1) : entry;

        // If there is a separator at the beginning or middle (or both) of the pattern, then
        // the pattern is relative to the directory level of the particular .gitignore file itself.
        // Otherwise, the pattern may also match at any level below the .gitignore level.
        int slashPos = pattern.indexOf(SLASH);
        // no slash or at end already
        if (slashPos == -1 || slashPos == pattern.length() - 1) {
            pattern = "**/" + pattern;
        }
        if (slashPos == 0) {
            pattern = pattern.substring(1);
        }
        // If there is a separator at the end of the pattern then the pattern will only match directories,
        // otherwise the pattern can match both files and directories.
        if (pattern.endsWith(SLASH)) {
            pattern = pattern.substring(0, pattern.length() - 1);
            String name = prefix ? NEGATION_PREFIX + pattern : pattern;
            DocumentName matcherPattern = DocumentName.builder(documentName).setName(name.replace(SLASH, documentName.getDirectorySeparator()))
                    .build();
            DocumentNameMatcher matcher = DocumentNameMatcher.and(new DocumentNameMatcher("isDirectory", File::isDirectory),
                    new DocumentNameMatcher(name, MatchPatterns.from(matcherPattern.localized(documentName.getDirectorySeparator()))));

            MatcherSet.Builder builder = new MatcherSet.Builder();
            if (prefix) {
                builder.addIncluded(matcher);
            } else {
                builder.addExcluded(matcher);
            }
            matcherSetConsumer.accept(builder.build());
            return Optional.empty();
        }
        return Optional.of(prefix ? NEGATION_PREFIX + pattern : pattern);
    }

    /**
     * The global gitignore file to process, based on the
     * {@link EnvVar#RAT_NO_GIT_GLOBAL_IGNORE},
     * {@link EnvVar#XDG_CONFIG_HOME} and
     * {@link EnvVar#HOME} environment
     * variables.
     */
    protected Optional<File> globalGitIgnore() {
        if (EnvVar.RAT_NO_GIT_GLOBAL_IGNORE.isSet()) {
            return Optional.empty();
        }

        String xdgConfigHome = EnvVar.XDG_CONFIG_HOME.getValue();
        String filename;
        if (xdgConfigHome != null && !xdgConfigHome.isEmpty()) {
            filename = xdgConfigHome + File.separator + "git" + File.separator + "ignore";
        } else {
            String home = StringUtils.defaultIfEmpty(EnvVar.HOME.getValue(), "");
            filename = home + File.separator + ".config" + File.separator + "git" + File.separator + "ignore";
        }
        File file = new File(filename);
        if (file.exists()) {
            return Optional.of(file);
        } else {
            return Optional.empty();
        }
    }
}