PathSelectorTest.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.impl;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class PathSelectorTest {
    /**
     * Creates a temporary directory and checks its list of content based on patterns.
     *
     * @param directory temporary directory where to create a tree
     * @throws IOException if an error occurred while creating a temporary file or directory
     */
    @Test
    public void testTree(final @TempDir Path directory) throws IOException {
        Path foo = Files.createDirectory(directory.resolve("foo"));
        Path bar = Files.createDirectory(foo.resolve("bar"));
        Path baz = Files.createDirectory(directory.resolve("baz"));
        Files.createFile(directory.resolve("root.txt"));
        Files.createFile(bar.resolve("leaf.txt"));
        Files.createFile(baz.resolve("excluded.txt"));
        assertFilteredFilesContains(directory, "", "root.txt", "foo/bar/leaf.txt");
        assertFilteredFilesContains(directory, "glob:", "foo/bar/leaf.txt");
    }

    /**
     * Asserts that the filtered set of paths contains the given items and nothing more.
     *
     * @param directory the temporary directory containing the files to test
     * @param syntax syntax to test, either an empty string of {@code "glob:"}
     * @param expected the expected paths
     * @throws IOException if an error occurred while listing the files
     */
    private static void assertFilteredFilesContains(final Path directory, final String syntax, final String... expected)
            throws IOException {
        List<String> includes = List.of(syntax + "**/*.txt");
        List<String> excludes = List.of(syntax + "baz/**");
        PathMatcher matcher = PathSelector.of(directory, includes, excludes, false);
        Set<Path> filtered =
                new HashSet<>(Files.walk(directory).filter(matcher::matches).toList());
        for (String path : expected) {
            assertTrue(filtered.remove(directory.resolve(path)), path);
        }
        assertTrue(filtered.isEmpty(), filtered.toString());
    }

    /**
     * Tests the omission of unnecessary excludes.
     *
     * Note: at the time of writing this test (April 2025), the list of excludes goes down from 40 to 17 elements.
     * This is not bad, but we could do better with, for example, a special treatment of the excludes that are
     * for excluding an entire directory.
     */
    @Test
    public void testExcludeOmission() {
        Path directory = Path.of("dummy");
        List<String> includes = List.of("**/*.java");
        List<String> excludes = List.of("baz/**");
        PathMatcher matcher = PathSelector.of(directory, includes, excludes, true);
        String s = matcher.toString();
        assertTrue(
                s.contains("glob:**/*.java") || s.contains("glob:{**/,}*.java"),
                "Expected " + s + " to contain " + "glob:**/*.java" + " or " + "glob:{**/,}*.java");
        assertFalse(
                s.contains("project.pj"),
                "Expected " + s + " to not contain " + "project.pj"); // Unnecessary exclusion should have been omitted.
        assertFalse(s.contains(".DS_Store"), "Expected " + s + " to not contain " + ".DS_Store");
    }

    /**
     * Test to verify the current behavior of ** patterns before implementing brace expansion improvement.
     * This test documents the expected behavior that must be preserved after the optimization.
     */
    @Test
    public void testDoubleAsteriskPatterns(final @TempDir Path directory) throws IOException {
        // Create a nested directory structure to test ** behavior
        Path src = Files.createDirectory(directory.resolve("src"));
        Path main = Files.createDirectory(src.resolve("main"));
        Path java = Files.createDirectory(main.resolve("java"));
        Path test = Files.createDirectory(src.resolve("test"));
        Path testJava = Files.createDirectory(test.resolve("java"));

        // Create files at different levels
        Files.createFile(directory.resolve("root.java"));
        Files.createFile(src.resolve("src.java"));
        Files.createFile(main.resolve("main.java"));
        Files.createFile(java.resolve("deep.java"));
        Files.createFile(test.resolve("test.java"));
        Files.createFile(testJava.resolve("testdeep.java"));

        // Test that ** matches zero or more directories (POSIX behavior)
        PathMatcher matcher = PathSelector.of(directory, List.of("src/**/test/**/*.java"), null, false);

        // Should match files in src/test/java/ (** matches zero dirs before test, zero dirs after test)
        assertTrue(matcher.matches(testJava.resolve("testdeep.java")));

        // Should also match files directly in src/test/ (** matches zero dirs after test)
        assertTrue(matcher.matches(test.resolve("test.java")));

        // Should NOT match files in other paths
        assertFalse(matcher.matches(directory.resolve("root.java")));
        assertFalse(matcher.matches(src.resolve("src.java")));
        assertFalse(matcher.matches(main.resolve("main.java")));
        assertFalse(matcher.matches(java.resolve("deep.java")));
    }

    @Test
    public void testLiteralBracesAreEscapedInMavenSyntax(@TempDir Path directory) throws IOException {
        // Create a file with literal braces in the name
        Files.createDirectories(directory.resolve("dir"));
        Path file = directory.resolve("dir/foo{bar}.txt");
        Files.createFile(file);

        // In Maven syntax (no explicit glob:), user-provided braces must be treated literally
        PathMatcher matcher = PathSelector.of(directory, List.of("**/foo{bar}.txt"), null, false);

        assertTrue(matcher.matches(file));
    }

    @Test
    public void testBraceAlternationOnlyWithExplicitGlob(@TempDir Path directory) throws IOException {
        // Create src/main/java and src/test/java with files
        Path mainJava = Files.createDirectories(directory.resolve("src/main/java"));
        Path testJava = Files.createDirectories(directory.resolve("src/test/java"));
        Path mainFile = Files.createFile(mainJava.resolve("Main.java"));
        Path testFile = Files.createFile(testJava.resolve("Test.java"));

        // Without explicit glob:, braces from user input are escaped and treated literally -> no matches
        PathMatcher mavenSyntax = PathSelector.of(directory, List.of("src/{main,test}/**/*.java"), null, false);
        assertFalse(mavenSyntax.matches(mainFile));
        assertFalse(mavenSyntax.matches(testFile));

        // With explicit glob:, braces should act as alternation and match both
        PathMatcher explicitGlob = PathSelector.of(directory, List.of("glob:src/{main,test}/**/*.java"), null, false);
        assertTrue(explicitGlob.matches(mainFile));
        assertTrue(explicitGlob.matches(testFile));
    }
}