GitIgnoreService.java

/*
 * SPDX-License-Identifier: Apache-2.0
 * Copyright 2026 Nikolas Charalambidis
 */

package dev.nichar.facepalm.engine;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.plugin.logging.SystemStreamLog;

/**
 * Manages {@code .gitignore} files to provide context-aware path filtering.
 * Parses project-level ignore rules to identify files with reduced public exposure.
 */
@Named
@Singleton
public class GitIgnoreService {

    private final Log log;

    /**
     * Map of directory paths to their compiled glob matchers.
     */
    private final Map<Path, List<PathMatcher>> registry = new TreeMap<>();

    @Inject
    public GitIgnoreService(@Nullable final Log log) {
        this.log = log != null ? log : new SystemStreamLog();
    }

    /**
     * Discovers and parses all {@code .gitignore} files within the target project.
     */
    public void loadAllGitIgnores(@Nonnull final Path root) {
        try {
            Files.walkFileTree(root, new SimpleFileVisitor<>() {

                @Override
                public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) {
                    if (file.getFileName() != null && ".gitignore".equals(file.getFileName().toString())) {
                        parseGitIgnoreFile(file);
                    }
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(final Path file, final IOException exc) {
                    // Unreadable directories are reported by the scanner; keep looking elsewhere.
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            log.warn("Error walking for .gitignores: " + e.getMessage());
        }
    }

    /**
     * Returns true if the specified path matches any registered ignore patterns.
     */
    public boolean isIgnored(@Nonnull final Path filePath) {
        return registry.entrySet().stream()
            .filter(entry -> filePath.startsWith(entry.getKey()))
            .flatMap(entry -> entry.getValue().stream())
            .anyMatch(matcher -> matcher.matches(filePath));
    }

    /**
     * Translates raw ignore patterns into functional {@link PathMatcher}s.
     */
    private void parseGitIgnoreFile(@Nonnull final Path ignoreFile) {
        final var directory = ignoreFile.getParent();
        final List<PathMatcher> matchers = new ArrayList<>();

        try {
            // Decode leniently: a .gitignore in a legacy encoding must not abort the scan.
            final var content = new String(Files.readAllBytes(ignoreFile), StandardCharsets.UTF_8);
            content.lines()
                .map(String::trim)
                // Skip empty lines and comments per Git specification.
                .filter(line -> !line.isEmpty() && !line.startsWith("#"))
                .forEach(pattern -> {
                    var glob = pattern;
                    if (pattern.endsWith("/")) {
                        // Convert directory-only matches to recursive globs.
                        glob += "**";
                    }
                    if (!pattern.startsWith("**/") && !pattern.startsWith("/")) {
                        // Ensure relative patterns match at any depth within the directory.
                        glob = "**/" + glob;
                    }
                    // Construct OS-agnostic glob strings.
                    // See: https://en.wikipedia.org/wiki/Glob_(programming)
                    final var fullGlob = "glob:" + escapeGlob(directory.toString().replace("\\", "/")) + "/" + glob;
                    try {
                        matchers.add(FileSystems.getDefault().getPathMatcher(fullGlob));
                    } catch (IllegalArgumentException e) {
                        // PatternSyntaxException for lines like "foo[" or "a{b": skip the line, keep the rest.
                        log.warn("Ignoring unsupported pattern '" + pattern + "' in " + ignoreFile + ": "
                            + e.getMessage());
                    }
                });
            registry.put(directory, matchers);
        } catch (IOException e) {
            log.warn("Could not read " + ignoreFile + ": " + e.getMessage());
        }
    }

    /**
     * Escapes glob metacharacters in a literal path, so a project directory like {@code my[app]} or {@code {x}}
     * is matched literally.
     */
    @Nonnull
    static String escapeGlob(@Nonnull final String literal) {
        final var escaped = new StringBuilder(literal.length());
        for (final char c : literal.toCharArray()) {
            if ("\\*?[]{}".indexOf(c) >= 0) {
                escaped.append('\\');
            }
            escaped.append(c);
        }
        return escaped.toString();
    }
}