diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index 845a9b3763f..949a7b6ad4d 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,7 +9,14 @@ */ namespace PHPUnit\TextUI\Configuration; +use PHPUnit\Util\FileMatcherPattern; +use function array_map; +use PHPUnit\Util\FileMatcher; +use PHPUnit\Util\FileMatcherRegex; + /** + * TODO: Does not take into account suffixes and prefixes - and tests don't cover it. + * * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * * @internal This class is not covered by the backward compatibility promise for PHPUnit @@ -17,35 +24,71 @@ final class SourceFilter { private static ?self $instance = null; + private Source $source; + + /** + * @var list + */ + private array $includeDirectoryRegexes; /** - * @var array + * @var list */ - private readonly array $map; + private array $excludeDirectoryRegexes; public static function instance(): self { if (self::$instance === null) { - self::$instance = new self( - (new SourceMapper)->map( - Registry::get()->source(), - ), - ); + $source = Registry::get()->source(); + self::$instance = new self($source); + + return self::$instance; } return self::$instance; } - /** - * @param array $map - */ - public function __construct(array $map) + public function __construct(Source $source) { - $this->map = $map; + $this->source = $source; + $this->includeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { + return FileMatcher::toRegEx(new FileMatcherPattern($directory->path())); + }, $source->includeDirectories()->asArray()); + $this->excludeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { + return FileMatcher::toRegEx(new FileMatcherPattern($directory->path())); + }, $source->excludeDirectories()->asArray()); } public function includes(string $path): bool { - return isset($this->map[$path]); + $included = false; + + foreach ($this->source->includeFiles() as $file) { + if ($file->path() === $path) { + $included = true; + } + } + + foreach ($this->includeDirectoryRegexes as $directoryRegex) { + if ($directoryRegex->matches($path)) { + $included = true; + } + } + + foreach ($this->source->excludeFiles() as $file) { + if ($file->path() === $path) { + $included = false; + } + } + + foreach ($this->excludeDirectoryRegexes as $directoryRegex) { + if ($directoryRegex->matches($path)) { + $included = false; + } + } + + return $included; } } diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php new file mode 100644 index 00000000000..b6445215cc5 --- /dev/null +++ b/src/Util/FileMatcher.php @@ -0,0 +1,293 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util; + +use function array_key_last; +use function array_pop; +use function count; +use function ctype_alpha; +use function preg_quote; +use function strlen; + +/** + * FileMatcher ultimately attempts to emulate the behavior `php-file-iterator` + * which *mostly* comes down to emulating PHP's glob function on file paths + * based on POSIX.2: + * + * - https://en.wikipedia.org/wiki/Glob_(programming) + * - https://man7.org/linux/man-pages/man7/glob.7.html + * + * The file matcher compiles the regex in three passes: + * + * - Tokenise interesting chars in the glob grammar. + * - Process the tokens and reorient them to produce regex. + * - Map the processed tokens to regular expression segments. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + * + * @phpstan-type token array{self::T_*,string} + */ +final readonly class FileMatcher +{ + private const string T_BRACKET_OPEN = 'bracket_open'; + private const string T_BRACKET_CLOSE = 'bracket_close'; + private const string T_BANG = 'bang'; + private const string T_HYPHEN = 'hyphen'; + private const string T_ASTERIX = 'asterix'; + private const string T_SLASH = 'slash'; + private const string T_BACKSLASH = 'backslash'; + private const string T_CHAR = 'char'; + private const string T_GREEDY_GLOBSTAR = 'greedy_globstar'; + private const string T_QUERY = 'query'; + private const string T_GLOBSTAR = 'globstar'; + private const string T_COLON = 'colon'; + private const string T_CHAR_CLASS = 'char_class'; + + /** + * Compile a regex for the given glob. + */ + public static function toRegEx(FileMatcherPattern $pattern): FileMatcherRegex + { + $tokens = self::tokenize($pattern->path); + $tokens = self::processTokens($tokens); + + return self::mapToRegex($tokens); + } + + /** + * @param list $tokens + */ + private static function mapToRegex(array $tokens): FileMatcherRegex + { + $regex = ''; + + foreach ($tokens as $token) { + $type = $token[0]; + $regex .= match ($type) { + // literal char + self::T_CHAR => preg_quote($token[1]), + + // literal directory separator + self::T_SLASH => '/', + self::T_QUERY => '.', + self::T_BANG => '^', + + // match any segment up until the next directory separator + self::T_ASTERIX => '[^/]*', + self::T_GREEDY_GLOBSTAR => '.*', + self::T_GLOBSTAR => '/([^/]+/)*', + self::T_BRACKET_OPEN => '[', + self::T_BRACKET_CLOSE => ']', + self::T_HYPHEN => '-', + self::T_COLON => ':', + self::T_BACKSLASH => '\\', + self::T_CHAR_CLASS => '[:' . $token[1] . ':]', + }; + } + $regex .= '(/|$)'; + + return new FileMatcherRegex('{^' . $regex . '}'); + } + + /** + * @return list + */ + private static function tokenize(string $glob): array + { + $length = strlen($glob); + + $tokens = []; + + for ($i = 0; $i < $length; $i++) { + $c = $glob[$i]; + + $tokens[] = match ($c) { + '[' => [self::T_BRACKET_OPEN, $c], + ']' => [self::T_BRACKET_CLOSE, $c], + '?' => [self::T_QUERY, $c], + '-' => [self::T_HYPHEN, $c], + '!' => [self::T_BANG, $c], + '*' => [self::T_ASTERIX, $c], + '/' => [self::T_SLASH, $c], + '\\' => [self::T_BACKSLASH, $c], + ':' => [self::T_COLON, $c], + default => [self::T_CHAR, $c], + }; + } + + return $tokens; + } + + /** + * @param list $tokens + * + * @return list + */ + private static function processTokens(array $tokens): array + { + $resolved = []; + $escaped = false; + $bracketOpen = false; + $brackets = []; + + for ($offset = 0; $offset < count($tokens); $offset++) { + [$type, $char] = $tokens[$offset]; + $nextType = $tokens[$offset + 1][0] ?? null; + + if ($type === self::T_BACKSLASH && false === $escaped) { + // skip the backslash and set flag to escape next token + $escaped = true; + + continue; + } + + if ($escaped === true) { + // escaped flag is set, so make this a literal char and unset + // the escaped flag + $resolved[] = [self::T_CHAR, $char]; + $escaped = false; + + continue; + } + + // globstar must be preceded by and succeeded by a directory separator + if ( + $type === self::T_SLASH && + $nextType === self::T_ASTERIX && ($tokens[$offset + 2][0] ?? null) === self::T_ASTERIX && ($tokens[$offset + 3][0] ?? null) === self::T_SLASH + ) { + $resolved[] = [self::T_GLOBSTAR, '**']; + + // we eat the two `*` and the trailing slash + $offset += 3; + + continue; + } + + // greedy globstar (trailing?) + // TODO: this should probably only apply at the end of the string according to the webmozart implementation and therefore would be "T_TRAILING_GLOBSTAR" + if ( + $type === self::T_SLASH && + ($tokens[$offset + 1][0] ?? null) === self::T_ASTERIX && ($tokens[$offset + 2][0] ?? null) === self::T_ASTERIX + ) { + $resolved[] = [self::T_GREEDY_GLOBSTAR, '**']; + + // we eat the two `*` in addition to the slash + $offset += 2; + + continue; + } + + // two consecutive ** which are not surrounded by `/` are invalid and + // we interpret them as literals. + if ($type === self::T_ASTERIX && ($tokens[$offset + 1][0] ?? null) === self::T_ASTERIX) { + $resolved[] = [self::T_CHAR, $char]; + $resolved[] = [self::T_CHAR, $char]; + + continue; + } + + // complementation - only parse BANG if it is at the start of a character group + if ($type === self::T_BANG && isset($resolved[array_key_last($resolved)]) && $resolved[array_key_last($resolved)][0] === self::T_BRACKET_OPEN) { + $resolved[] = [self::T_BANG, '!']; + + continue; + } + + // if this was _not_ a bang preceded by a `[` token then convert it + // to a literal char + if ($type === self::T_BANG) { + $resolved[] = [self::T_CHAR, $char]; + + continue; + } + + // https://man7.org/linux/man-pages/man7/glob.7.html + // > The string enclosed by the brackets cannot be empty; therefore + // > ']' can be allowed between the brackets, provided that it is + // > the first character. + if ($type === self::T_BRACKET_OPEN && $nextType === self::T_BRACKET_CLOSE) { + $bracketOpen = true; + $resolved[] = [self::T_BRACKET_OPEN, '[']; + $brackets[] = array_key_last($resolved); + $resolved[] = [self::T_CHAR, ']']; + $offset++; + + continue; + } + + // if we're already in a bracket and the next two chars are [: then + // start parsing a character class... + if ($bracketOpen && $type === self::T_BRACKET_OPEN && $nextType === self::T_COLON) { + // this looks like a named [:character:] class + $class = ''; + $offset += 2; + + // parse the character class name + while (ctype_alpha($tokens[$offset][1])) { + $class .= $tokens[$offset++][1]; + } + + // if followed by a `:` then it's a character class + if ($tokens[$offset][0] === self::T_COLON) { + $offset++; + $resolved[] = [self::T_CHAR_CLASS, $class]; + + continue; + } + + // otherwise it's a harmless literal + $resolved[] = [self::T_CHAR, ':' . $class]; + } + + // if bracket is already open and we have another open bracket + // interpret it as a literal + if ($bracketOpen === true && $type === self::T_BRACKET_OPEN) { + $resolved[] = [self::T_CHAR, $char]; + + continue; + } + + // if we are NOT in an open bracket and we have an open bracket + // then pop the bracket on the stack and enter bracket-mode. + if ($bracketOpen === false && $type === self::T_BRACKET_OPEN) { + $bracketOpen = true; + $resolved[] = [$type, $char]; + $brackets[] = array_key_last($resolved); + + continue; + } + + // if are in a bracket and we get to bracket close then + // pop the last open bracket off the stack and continue + // + // TODO: $bracketOpen === true below is not tested + if ($bracketOpen === true && $type === self::T_BRACKET_CLOSE) { + // TODO: this is not tested + $bracketOpen = false; + + array_pop($brackets); + $resolved[] = [$type, $char]; + + continue; + } + + $resolved[] = [$type, $char]; + } + + // foreach unterminated bracket replace it with a literal char + foreach ($brackets as $unterminatedBracket) { + $resolved[$unterminatedBracket] = [self::T_CHAR, '[']; + } + + return $resolved; + } +} diff --git a/src/Util/FileMatcherPattern.php b/src/Util/FileMatcherPattern.php new file mode 100644 index 00000000000..66bb15c7d05 --- /dev/null +++ b/src/Util/FileMatcherPattern.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util; + +final class FileMatcherPattern +{ + public function __construct(public string $path) + { + } +} diff --git a/src/Util/FileMatcherRegex.php b/src/Util/FileMatcherRegex.php new file mode 100644 index 00000000000..b585080cdb0 --- /dev/null +++ b/src/Util/FileMatcherRegex.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util; + +use function preg_match; +use function sprintf; +use function substr; +use RuntimeException; + +final class FileMatcherRegex +{ + public function __construct(private string $regex) + { + } + + public function matches(string $path): bool + { + self::assertIsAbsolute($path); + + return preg_match($this->regex, $path) !== 0; + } + + private static function assertIsAbsolute(string $path): void + { + if (substr($path, 0, 1) !== '/') { + throw new RuntimeException(sprintf( + 'Path "%s" must be absolute', + $path, + )); + } + } +} diff --git a/tests/unit/TextUI/SourceFilterTest.php b/tests/unit/TextUI/SourceFilterTest.php index fe41843333e..7c8a70fd43d 100644 --- a/tests/unit/TextUI/SourceFilterTest.php +++ b/tests/unit/TextUI/SourceFilterTest.php @@ -422,7 +422,7 @@ public function testDeterminesWhetherFileIsIncluded(array $expectations, Source $this->assertFileExists($file); $this->assertSame( $shouldInclude, - (new SourceFilter((new SourceMapper)->map($source)))->includes($file), + (new SourceFilter($source))->includes($file), sprintf('expected match to return %s for: %s', json_encode($shouldInclude), $file), ); } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php new file mode 100644 index 00000000000..18481b40a3f --- /dev/null +++ b/tests/unit/Util/FileMatcherTest.php @@ -0,0 +1,640 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util; + +use function sprintf; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +#[CoversClass(FileMatcher::class)] +#[Small] +class FileMatcherTest extends TestCase +{ + /** + * @return Generator}> + */ + public static function provideMatch(): Generator + { + yield 'exact path' => [ + new FileMatcherPattern('/path/to/example/Foo.php'), + [ + '/path/to/example/Foo.php' => true, + '/path/to/example/Bar.php' => false, + ], + ]; + + yield 'directory' => [ + new FileMatcherPattern('/path/to'), + [ + '/path/to' => true, + '/path/to/example/Foo.php' => true, + '/path/foo/Bar.php' => false, + ], + ]; + } + + /** + * @return Generator}> + */ + public static function provideWildcard(): Generator + { + yield 'leaf wildcard' => [ + new FileMatcherPattern('/path/*'), + [ + '/path/foo/bar' => true, + '/path/foo/baz' => true, + '/path/baz.php' => true, + '/path/foo/baz/boo.php' => true, + '/path/example/file.php' => true, + '/' => false, + ], + ]; + + yield 'leaf directory wildcard' => [ + new FileMatcherPattern('/path/*'), + [ + '/path/foo/bar' => true, + '/path/foo/baz' => true, + '/path/foo/baz/boo.php' => true, + '/path/example/file.php' => true, + '/' => false, + ], + ]; + + yield 'segment directory wildcard' => [ + new FileMatcherPattern('/path/*/bar'), + [ + '/path/foo/bar' => true, + '/path/foo/baz' => false, + '/path/foo/bar/boo.php' => true, + '/foo/bar/file.php' => false, + ], + ]; + + yield 'multiple segment directory wildcards' => [ + new FileMatcherPattern('/path/*/example/*/bar'), + [ + '/path/zz/example/aa/bar' => true, + '/path/zz/example/aa/bar/foo' => true, + '/path/example/aa/bar/foo' => false, + '/path/zz/example/bb/foo' => false, + ], + ]; + + yield 'partial wildcard' => [ + new FileMatcherPattern('/path/f*'), + [ + '/path/foo/bar' => true, + '/path/foo/baz' => true, + '/path/boo' => false, + '/path/boo/example/file.php' => false, + ], + ]; + + yield 'partial segment wildcard' => [ + new FileMatcherPattern('/path/f*/bar'), + [ + '/path/foo/bar' => true, + '/path/faa/bar' => true, + '/path/foo/baz' => false, + '/path/boo' => false, + '/path/boo/example/file.php' => false, + ], + ]; + } + + /** + * @return Generator}> + */ + public static function provideGlobstar(): Generator + { + yield 'leaf globstar at root' => [ + new FileMatcherPattern('/**'), + [ + '/foo' => true, + '/foo/bar' => true, + '/' => true, // matches zero or more + ], + ]; + + yield 'leaf globstar' => [ + new FileMatcherPattern('/foo/**'), + [ + '/foo' => true, + '/foo/foo' => true, + '/foo/foo/baz.php' => true, + '/bar/foo' => false, + '/bar/foo/baz' => false, + ], + ]; + + // partial match does not work with globstar + yield 'partial leaf globstar' => [ + new FileMatcherPattern('/foo/emm**'), + [ + '/foo/emm**' => true, + '/foo/emmer' => false, + '/foo/emm' => false, + '/foo/emm/bar' => false, + '/' => false, + ], + ]; + + yield 'segment globstar' => [ + new FileMatcherPattern('/foo/emm/**/bar'), + [ + '/foo/emm/bar' => true, + '/foo/emm/foo/bar' => true, + '/baz/emm/foo/bar/boo' => false, + '/baz/emm/foo/bar' => false, + '/foo/emm/barfoo' => false, + '/foo/emm/' => false, + '/foo/emm' => false, + ], + ]; + + // PHPUnit will match ALL directories within `/foo` with `/foo/A**` + // however it will NOT match anything with `/foo/Aa**` + // + // This is likely a bug and so we could consider "fixing" it + yield 'EDGE: segment globstar with wildcard' => [ + new FileMatcherPattern('/foo/emm/**/*ar'), + [ + '/foo/emm/bar' => true, + '/foo/emm/far' => true, + '/foo/emm/foo/far' => true, + '/foo/emm/foo/far' => true, + '/foo/emm/foo/bar/far' => true, + '/baz/emm/foo/bar/boo' => true, + '/baz/emm/foo/bad' => false, + '/baz/emm/foo/bad/boo' => false, + ], + 'PHPUnit edge case', + ]; + } + + /** + * @return Generator}> + */ + public static function provideQuestionMark(): Generator + { + yield 'question mark at root' => [ + new FileMatcherPattern('/?'), + [ + '/' => false, + '/f' => true, + '/foo' => false, + '/f/emm/foo/bar' => true, + '/foo/emm/foo/bar' => false, + ], + ]; + + yield 'question mark at leaf' => [ + new FileMatcherPattern('/foo/?'), + [ + '/foo' => false, + '/foo/' => false, + '/foo/a' => true, + '/foo/ab' => false, + '/foo/a/c' => true, + ], + ]; + + yield 'question mark at segment start' => [ + new FileMatcherPattern('/foo/?ar'), + [ + '/' => false, + '/foo' => false, + '/foo/' => false, + '/foo/aa' => false, + '/foo/aar' => true, + '/foo/aarg' => false, + '/foo/aar/barg' => true, + '/foo/bar' => true, + '/foo/ab/c' => false, + ], + ]; + + yield 'question mark in segment' => [ + new FileMatcherPattern('/foo/f?o'), + [ + '/foo' => false, + '/foo/' => false, + '/foo/foo' => true, + '/foo/boo' => false, + '/foo/foo/true' => true, + ], + ]; + + yield 'consecutive question marks' => [ + new FileMatcherPattern('/foo/???'), + [ + '/foo' => false, + '/foo/' => false, + '/foo/bar' => true, + '/foo/car' => true, + '/foo/the/test/will/pass' => true, + '/bar/the/test/will/not/pass' => false, + ], + ]; + + yield 'multiple question marks in segment' => [ + new FileMatcherPattern('/foo/?a?'), + [ + '/foo/car' => true, + '/foo/ccr' => false, + ], + ]; + + yield 'multiple question marks in segments' => [ + new FileMatcherPattern('/foo/?a?/bar/f?a'), + [ + '/foo' => false, + '/foo/aaa' => false, + '/foo/aaa/bar' => false, + '/foo/aaa/bar/' => false, + '/foo/bar/zaa' => false, + '/foo/car/bar/faa' => true, + ], + ]; + + yield 'tailing question mark' => [ + new FileMatcherPattern('/foo/?a?/bar/fa?'), + [ + '/foo/car' => false, + '/foo/car/bar/faa' => true, + '/foo/ccr' => false, + '/foo/bar/zaa' => false, + ], + ]; + } + + /** + * @return Generator}> + */ + public static function provideCharacterGroup(): Generator + { + yield 'unterminated char group' => [ + new FileMatcherPattern('/[AB'), + [ + '/[' => false, + '/[A' => false, + '/[AB' => true, + '/[AB/foo' => true, + ], + ]; + + yield 'unterminated char group followed by char group' => [ + new FileMatcherPattern('/[AB[a-z]'), + [ + '/[' => true, // nested [ is literal + '/f' => true, // within a-z + '/A' => true, + '/B' => true, + + '/Z' => false, + '/[c' => false, + ], + ]; + + yield 'multiple unterminated char groups followed by char group' => [ + new FileMatcherPattern('/[AB[CD[a-z]EF'), + [ + '/[EF' => true, + '/AEF' => true, + '/[EF' => true, + '/DEF' => true, + '/EEF' => false, + ], + ]; + + yield 'single char leaf' => [ + new FileMatcherPattern('/[A]'), + [ + '/A' => true, + '/B' => false, + ], + ]; + + yield 'single char segment' => [ + new FileMatcherPattern('/a/[B]/c'), + [ + '/a' => false, + '/a/B' => false, + '/a/B/c' => true, + '/a/Z/c' => false, + ], + ]; + + yield 'multichar' => [ + new FileMatcherPattern('/a/[ABC]/c'), + [ + '/a' => false, + '/a/A' => false, + '/a/B/c' => true, + '/a/C/c' => true, + '/a/Z/c' => false, + '/a/Za/c' => false, + '/a/Aaa/c' => false, + ], + ]; + + yield 'matching is case sensitive' => [ + new FileMatcherPattern('/a/[ABC]/c'), + [ + '/a/a' => false, + '/a/b/c' => false, + '/a/c/c' => false, + ], + ]; + + // https://man7.org/linux/man-pages/man7/glob.7.html + yield 'square bracket in char group' => [ + new FileMatcherPattern('/[][!]*'), + [ + '/[hello' => true, + '/[' => true, + '/!' => true, + '/!bang' => true, + '/a' => false, + '/' => false, + ], + ]; + + yield 'match ranges' => [ + new FileMatcherPattern('/a/[a-c]/c'), + [ + '/a/a' => false, + '/a/z/c' => false, + '/a/b/c' => true, + '/a/c/c' => true, + '/a/d/c' => false, + '/a/c/d' => false, + ], + ]; + + yield 'multiple match ranges' => [ + new FileMatcherPattern('/a/[a-c0-8]/c'), + [ + '/a/a' => false, + '/a/0/c' => true, + '/a/2/c' => true, + '/a/8/c' => true, + '/a/9/c' => false, + '/a/c/c' => true, + '/a/a/c' => true, + '/a/d/c' => false, + ], + ]; + + yield 'dash in group' => [ + new FileMatcherPattern('/a/[-]/c'), + [ + '/a/-' => false, + '/a/-/c' => true, + '/a/-/ca/d' => false, + '/a/-/c/da' => true, + '/a/a/fo' => false, + ], + ]; + + yield 'range prefix dash' => [ + new FileMatcherPattern('/a/[-a-c]/c'), + [ + '/a/a' => false, + '/a/-' => false, + '/a/-/c' => true, + '/a/d' => false, + '/a/-b/c' => false, + '/a/a/c/fo' => true, + '/a/c/fo' => false, + '/a/d/c' => false, + ], + ]; + + yield 'range infix dash' => [ + new FileMatcherPattern('/a/[a-c-e-f]/c'), + [ + '/a/a' => false, + '/a/-/c' => true, + '/a/-/a' => false, + '/a/c/c' => true, + '/a/a/c' => true, + '/a/d/c' => false, + '/a/e/c' => true, + '/a/g/c' => false, + '/a/-/c' => true, + ], + ]; + + yield 'range suffix dash' => [ + new FileMatcherPattern('/a/[a-ce-f-]/c'), + [ + '/a/a' => false, + '/a/-/c' => true, + '/a/-/c' => true, + '/a/c/c' => true, + '/a/a/c' => true, + '/a/d/c' => false, + '/a/e/c' => true, + '/a/g/c' => false, + '/a/-/c' => true, + ], + ]; + + yield 'complementation single char' => [ + new FileMatcherPattern('/a/[!a]/c'), + [ + '/a/a' => false, + '/a/a/c' => false, + '/a/b/c' => true, + '/a/0/c' => true, + '/a/0a/c' => false, + ], + ]; + + yield 'complementation multi char' => [ + new FileMatcherPattern('/a/[!abc]/c'), + [ + '/a/a/c' => false, + '/a/b/c' => false, + '/a/c/c' => false, + '/a/d/c' => true, + ], + ]; + + yield 'complementation range' => [ + new FileMatcherPattern('/a/[!a-c]/c'), + [ + '/a/a/c' => false, + '/a/b/c' => false, + '/a/c/c' => false, + '/a/d/c' => true, + ], + ]; + + yield 'escape range' => [ + new FileMatcherPattern('/a/\[!a-c]/c'), + [ + '/a/[!a-c]/c' => true, + '/a/[!a-c]/c/d' => true, + '/b/[!a-c]/c/d' => false, + ], + ]; + + yield 'literal backslash negated group' => [ + new FileMatcherPattern('/a/\\\[!a-c]/c'), + [ + '/a/\\d/c' => true, + ], + ]; + + // TODO: test all the character clases + // [:alnum:] [:alpha:] [:blank:] [:cntrl:] + // [:digit:] [:graph:] [:lower:] [:print:] + // [:punct:] [:space:] [:upper:] [:xdigit:] + yield 'character class [:alnum:]' => [ + new FileMatcherPattern('/a/[[:alnum:]]/c'), + [ + '/a/1/c' => true, + '/a/2/c' => true, + '/b/!/c' => false, + ], + ]; + + yield 'character class [:digit:]' => [ + new FileMatcherPattern('/a/[[:digit:]]/c'), + [ + '/a/1/c' => true, + '/a/2/c' => true, + '/b/!/c' => false, + '/b/b/c' => false, + ], + ]; + + yield 'multiple character classes' => [ + new FileMatcherPattern('/a/[[:digit:][:lower:]]/c'), + [ + '/a/1/c' => true, + '/a/2/c' => true, + '/b/!/c' => false, + '/a/b/c' => true, + ], + ]; + + yield 'multiple character classes and range' => [ + new FileMatcherPattern('/a/[@[:upper:][:lower:]5-7]/c'), + [ + '/a/b/c' => true, + '/a/B/c' => true, + '/a/5/c' => true, + '/a/7/c' => true, + '/a/@/c' => true, + ], + ]; + + // TODO: ... + // Collating symbols, like "[.ch.]" or "[.a-acute.]", where the + // string between "[." and ".]" is a collating element defined for + // the current locale. Note that this may be a multicharacter + // element + yield 'collating symbols' => [ + new FileMatcherPattern('/a/[.a-acute.]/c'), + [ + '/a/á/c' => true, + '/a/a/c' => false, + ], + 'Collating symbols', + ]; + + // TODO: ... + // Equivalence class expressions, like "[=a=]", where the string + // between "[=" and "=]" is any collating element from its + // equivalence class, as defined for the current locale. For + // example, "[[=a=]]" might be equivalent to "[aáàäâ]", that is, to + // "[a[.a-acute.][.a-grave.][.a-umlaut.][.a-circumflex.]]". + yield 'equivalence class expressions' => [ + new FileMatcherPattern('/a/[=a=]/c'), + [ + '/a/á/c' => true, + '/a/a/c' => true, + ], + 'Equaivalence class expressions', + ]; + } + + /** + * TODO: expand this. + * + * @return Generator}> + */ + public static function provideRelativePathSegments(): Generator + { + yield 'dot dot' => [ + new FileMatcherPattern('/a/../a/c'), + [ + '/a/a/c' => true, + '/a/b/c' => true, + ], + 'Relative path segments', + ]; + } + + /** + * @param array $matchMap + */ + #[DataProvider('provideMatch')] + #[DataProvider('provideWildcard')] + #[DataProvider('provideGlobstar')] + #[DataProvider('provideQuestionMark')] + #[DataProvider('provideCharacterGroup')] + #[DataProvider('provideRelativePathSegments')] + public function testMatch(FileMatcherPattern $pattern, array $matchMap, ?string $skip = null): void + { + if ($skip) { + $this->markTestSkipped($skip); + } + + self::assertMap($pattern, $matchMap); + } + + public function testExceptionIfPathIsNotAbsolute(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path "foo/bar" must be absolute'); + FileMatcher::toRegEx(new FileMatcherPattern('/a'))->matches('foo/bar'); + } + + /** + * @param array $matchMap + */ + private static function assertMap(FileMatcherPattern $pattern, array $matchMap): void + { + foreach ($matchMap as $candidate => $shouldMatch) { + $matches = FileMatcher::toRegEx($pattern)->matches($candidate); + + if ($matches === $shouldMatch) { + self::assertTrue(true); + + continue; + } + self::fail(sprintf( + 'Expected the pattern "%s" %s match path "%s"', + $pattern->path, + $shouldMatch ? 'to' : 'to not', + $candidate, + )); + } + } +} diff --git a/tools/map b/tools/map new file mode 100755 index 00000000000..28c24c62664 --- /dev/null +++ b/tools/map @@ -0,0 +1,42 @@ +#!/usr/bin/env php +map(new Source( + baseline: null, + ignoreBaseline: false, + includeDirectories: FilterDirectoryCollection::fromArray([new FilterDirectory($dirs, '', '')]), + includeFiles: FileCollection::fromArray([]), + excludeDirectories: FilterDirectoryCollection::fromArray([]), + excludeFiles: FileCollection::fromArray([]), + restrictDeprecations: false, + restrictNotices: false, + restrictWarnings: false, + ignoreSuppressionOfDeprecations: false, + ignoreSuppressionOfPhpDeprecations: false, + ignoreSuppressionOfErrors: false, + ignoreSuppressionOfNotices: false, + ignoreSuppressionOfPhpNotices: false, + ignoreSuppressionOfWarnings: false, + ignoreSuppressionOfPhpWarnings: false, + deprecationTriggers: [], + ignoreSelfDeprecations: false, + ignoreDirectDeprecations: false, + ignoreIndirectDeprecations: false +)); + + +var_dump($map);