From 0ec8bafeefa6d8863f7b2788f75421053593e348 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Fri, 7 Mar 2025 11:55:40 +0000 Subject: [PATCH 01/32] Initial stub implementations and test cases --- src/Util/FileMatcher.php | 39 ++++++++ src/Util/FileMatcherPattern.php | 11 +++ tests/unit/Util/FileMatcherTest.php | 141 ++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/Util/FileMatcher.php create mode 100644 src/Util/FileMatcherPattern.php create mode 100644 tests/unit/Util/FileMatcherTest.php diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php new file mode 100644 index 00000000000..95ed44d30fd --- /dev/null +++ b/src/Util/FileMatcher.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 RuntimeException; +use const DIRECTORY_SEPARATOR; +use function basename; +use function dirname; +use function is_dir; +use function mkdir; +use function realpath; +use function str_starts_with; + +/** + * @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 + */ +final readonly class FileMatcher +{ + public static function match(string $path, FileMatcherPattern $pattern): bool + { + if (substr($path, 0, 1) !== '/') { + throw new RuntimeException(sprintf( + 'Path "%s" must be absolute', + $path + )); + } + return false; + } +} + diff --git a/src/Util/FileMatcherPattern.php b/src/Util/FileMatcherPattern.php new file mode 100644 index 00000000000..6b5ab31dc27 --- /dev/null +++ b/src/Util/FileMatcherPattern.php @@ -0,0 +1,11 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Path "foo/bar" must be absolute'); + FileMatcher::match('foo/bar', new FileMatcherPattern('')); + } + + /** + * @param array $matchMap + */ + #[DataProvider('provideMatch')] + public function testMatch(FileMatcherPattern $pattern, array $matchMap): void + { + self::assertMap($pattern, $matchMap); + } + + /** + * @param array $matchMap + */ + #[DataProvider('provideWildcard')] + public function testWildcard(FileMatcherPattern $pattern, array $matchMap): void + { + self::assertMap($pattern, $matchMap); + } + + /** + * @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' => false, + '/' => false, + '' => 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' => false, + '/' => false, + '' => 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, + ], + ]; + } + + /** + * @param array $matchMap + */ + private static function assertMap(FileMatcherPattern $pattern, array $matchMap): void + { + foreach ($matchMap as $candidate => $shouldMatch) { + self::assertSame($shouldMatch, FileMatcher::match($candidate, $pattern)); + } + } +} From 875a8162da297eaa57c61ede7c9c0dfcc5dafd98 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Fri, 7 Mar 2025 11:55:56 +0000 Subject: [PATCH 02/32] Temporary utility method to explore current behavior --- tools/map | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 tools/map diff --git a/tools/map b/tools/map new file mode 100755 index 00000000000..797eb23f7cf --- /dev/null +++ b/tools/map @@ -0,0 +1,38 @@ +#!/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); From e6259a4a5196d99ad614ca045ca168d03a754962 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Mon, 10 Mar 2025 09:57:21 +0000 Subject: [PATCH 03/32] Added initial test cases --- tests/unit/Util/FileMatcherTest.php | 419 +++++++++++++++++++++++++++- 1 file changed, 408 insertions(+), 11 deletions(-) diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index bdd253753b6..5e067d78b05 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -3,10 +3,14 @@ namespace PHPUnit\Util; 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 { public function testExceptionIfPathIsNotAbsolute(): void @@ -20,16 +24,12 @@ public function testExceptionIfPathIsNotAbsolute(): void * @param array $matchMap */ #[DataProvider('provideMatch')] - public function testMatch(FileMatcherPattern $pattern, array $matchMap): void - { - self::assertMap($pattern, $matchMap); - } - - /** - * @param array $matchMap - */ #[DataProvider('provideWildcard')] - public function testWildcard(FileMatcherPattern $pattern, array $matchMap): void + #[DataProvider('provideGlobstar')] + #[DataProvider('provideQuestionMark')] + #[DataProvider('provideCharacterGroup')] + #[DataProvider('provideRelativePathSegments')] + public function testMatch(FileMatcherPattern $pattern, array $matchMap): void { self::assertMap($pattern, $matchMap); } @@ -62,7 +62,6 @@ public static function provideMatch(): Generator */ public static function provideWildcard(): Generator { - yield 'leaf wildcard' => [ new FileMatcherPattern('/path/*'), [ @@ -129,13 +128,411 @@ public static function provideWildcard(): Generator ]; } + /** + * @return Generator}> + */ + public static function provideGlobstar(): Generator + { + yield 'leaf globstar at root' => [ + new FileMatcherPattern('/**'), + [ + '/foo' => true, + '/foo/bar' => true, + '/' => false, + ], + ]; + + 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/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' => true, + '/baz/emm/foo/bar' => false, + '/foo/emm/barfoo' => false, + '/foo/emm/' => false, + '/foo/emm' => false, + ], + ]; + + yield '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, + ], + ]; + } + + /** + * @return Generator}> + */ + public static function provideQuestionMark(): Generator + { + yield 'question mark at root' => [ + new FileMatcherPattern('/?'), + [ + '/' => false, + '/f' => true, + '/foo' => true, + '/foo/emm/foo/bar' => true, + ], + ]; + yield 'question mark at leaf' => [ + new FileMatcherPattern('/foo/?'), + [ + '/foo' => false, + '/foo/' => false, + '/foo/a' => true, + '/foo/ab' => true, + '/foo/ab/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' => true, + '/foo/aarg/barg' => true, + '/foo/bar' => true, + '/foo/ab/c' => true, + ], + ]; + 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/faa' => true, + ], + ]; + yield 'tailing question mark' => [ + new FileMatcherPattern('/foo/?a?/bar/fa?'), + [ + '/foo/car' => true, + '/foo/car/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 'single char leaf' => [ + new FileMatcherPattern('/[A]'), + [ + '/A' => true, + '/B' => false, + ], + ]; + yield 'single char segment' => [ + new FileMatcherPattern('/a/[B]/c'), + [ + '/a' => false, + '/a/B' => true, + '/a/B/c' => true, + '/a/Z/c' => false, + ], + ]; + yield 'multichar' => [ + new FileMatcherPattern('/a/[ABC]/c'), + [ + '/a' => false, + '/a/A' => true, + '/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 + // example from glob manpage + 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/-' => true, + '/a/-/fo' => true, + '/a/a/fo' => false, + ], + ]; + + yield 'range prefix dash' => [ + new FileMatcherPattern('/a/[-a-c]/c'), + [ + '/a/a' => false, + '/a/-' => true, + '/a/d' => false, + '/a/-b/c' => false, + '/a/a/fo' => true, + '/a/c/fo' => true, + '/a/d/fo' => false, + ], + ]; + + yield 'range infix dash' => [ + new FileMatcherPattern('/a/[a-c-e-f]/c'), + [ + '/a/a' => false, + '/a/-' => true, + '/a/-/a' => true, + '/a/c/a' => true, + '/a/a/a' => true, + '/a/d/a' => false, + '/a/e/a' => true, + '/a/g/a' => false, + '/a/-/c' => true, + ], + ]; + + yield 'range suffix dash' => [ + new FileMatcherPattern('/a/[a-ce-f-]/c'), + [ + '/a/a' => false, + '/a/-' => true, + '/a/-/a' => true, + '/a/c/a' => true, + '/a/a/a' => true, + '/a/d/a' => false, + '/a/e/a' => true, + '/a/g/a' => false, + '/a/-/c' => true, + ], + ]; + + yield 'complementation single char' => [ + new FileMatcherPattern('/a/[!a]/c'), + [ + '/a/a' => false, + '/a/a/b' => false, + '/a/b/b' => true, + '/a/0/b' => true, + '/a/0a/b' => false, + ] + ]; + + yield 'complementation multi char' => [ + new FileMatcherPattern('/a/[!abc]/c'), + [ + '/a/a/b' => false, + '/a/b/b' => false, + '/a/c/b' => false, + '/a/d/b' => true, + ] + ]; + + yield 'complementation range' => [ + new FileMatcherPattern('/a/[!a-c]/c'), + [ + '/a/a/b' => false, + '/a/b/b' => false, + '/a/c/b' => false, + '/a/d/b' => 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, + ] + ]; + + // TODO: test all the character clases + // [:alnum:] [:alpha:] [:blank:] [:cntrl:] + // [:digit:] [:graph:] [:lower:] [:print:] + // [:punct:] [:space:] [:upper:] [:xdigit:] + yield 'character class...' => [ + new FileMatcherPattern('/a/[:alnum:]/c'), + [ + '/a/1/c' => true, + '/a/2/c' => true, + '/b/!/c' => false, + ] + ]; + + // TODO: all of these? + // 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, + ] + ]; + + // TODO: all of these? + // 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, + ] + + ]; + } + + /** + * TODO: expand this + * @return Generator}> + */ + public static function provideRelativePathSegments(): Generator + { + yield 'equivalence class expressions' => [ + new FileMatcherPattern('/a/../a/c'), + [ + '/a/a/c' => true, + '/a/b/c' => true, + ] + + ]; + } /** * @param array $matchMap */ private static function assertMap(FileMatcherPattern $pattern, array $matchMap): void { foreach ($matchMap as $candidate => $shouldMatch) { - self::assertSame($shouldMatch, FileMatcher::match($candidate, $pattern)); + $matches = FileMatcher::match($candidate, $pattern); + if ($matches === $shouldMatch) { + $this->addToAssertionCount(1); + continue; + } + self::fail(sprintf('Expected the pattern "%s" to match path "%s"', $pattern->path, $candidate)); } } } From 8bf40388cb77d1cb0b7b57dde7d44f3dfac5b1f1 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Mon, 10 Mar 2025 10:46:47 +0000 Subject: [PATCH 04/32] Add edge case --- tests/unit/Util/FileMatcherTest.php | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 5e067d78b05..95b244b2017 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -69,9 +69,8 @@ public static function provideWildcard(): Generator '/path/foo/baz' => true, '/path/baz.php' => true, '/path/foo/baz/boo.php' => true, - '/path/example/file.php' => false, + '/path/example/file.php' => true, '/' => false, - '' => false, ], ]; @@ -81,9 +80,8 @@ public static function provideWildcard(): Generator '/path/foo/bar' => true, '/path/foo/baz' => true, '/path/foo/baz/boo.php' => true, - '/path/example/file.php' => false, + '/path/example/file.php' => true, '/' => false, - '' => false, ], ]; yield 'segment directory wildcard' => [ @@ -138,7 +136,7 @@ public static function provideGlobstar(): Generator [ '/foo' => true, '/foo/bar' => true, - '/' => false, + '/' => true, // matches zero or more ], ]; @@ -177,7 +175,11 @@ public static function provideGlobstar(): Generator ], ]; - yield 'segment globstar with wildcard' => [ + // 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, @@ -512,7 +514,7 @@ public static function provideCharacterGroup(): Generator */ public static function provideRelativePathSegments(): Generator { - yield 'equivalence class expressions' => [ + yield 'dot dot' => [ new FileMatcherPattern('/a/../a/c'), [ '/a/a/c' => true, @@ -529,10 +531,15 @@ private static function assertMap(FileMatcherPattern $pattern, array $matchMap): foreach ($matchMap as $candidate => $shouldMatch) { $matches = FileMatcher::match($candidate, $pattern); if ($matches === $shouldMatch) { - $this->addToAssertionCount(1); + self::assertTrue(true); continue; } - self::fail(sprintf('Expected the pattern "%s" to match path "%s"', $pattern->path, $candidate)); + self::fail(sprintf( + 'Expected the pattern "%s" %s match path "%s"', + $pattern->path, + $shouldMatch ? 'to' : 'to not', + $candidate + )); } } } From 0d51a5866501a3def096f4e30bde6978f7df5a74 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Mon, 10 Mar 2025 11:22:24 +0000 Subject: [PATCH 05/32] Fixing tests --- tests/unit/Util/FileMatcherTest.php | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 95b244b2017..2d06dce9cc4 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -167,7 +167,7 @@ public static function provideGlobstar(): Generator [ '/foo/emm/bar' => true, '/foo/emm/foo/bar' => true, - '/baz/emm/foo/bar/boo' => true, + '/baz/emm/foo/bar/boo' => false, '/baz/emm/foo/bar' => false, '/foo/emm/barfoo' => false, '/foo/emm/' => false, @@ -175,6 +175,8 @@ public static function provideGlobstar(): Generator ], ]; + // TODO: this edge case + return; // PHPUnit will match ALL directories within `/foo` with `/foo/A**` // however it will NOT match anything with `/foo/Aa**` // @@ -204,8 +206,9 @@ public static function provideQuestionMark(): Generator [ '/' => false, '/f' => true, - '/foo' => true, - '/foo/emm/foo/bar' => true, + '/foo' => false, + '/f/emm/foo/bar' => true, + '/foo/emm/foo/bar' => false, ], ]; yield 'question mark at leaf' => [ @@ -214,8 +217,8 @@ public static function provideQuestionMark(): Generator '/foo' => false, '/foo/' => false, '/foo/a' => true, - '/foo/ab' => true, - '/foo/ab/c' => true, + '/foo/ab' => false, + '/foo/a/c' => true, ], ]; yield 'question mark at segment start' => [ @@ -226,10 +229,10 @@ public static function provideQuestionMark(): Generator '/foo/' => false, '/foo/aa' => false, '/foo/aar' => true, - '/foo/aarg' => true, - '/foo/aarg/barg' => true, + '/foo/aarg' => false, + '/foo/aar/barg' => true, '/foo/bar' => true, - '/foo/ab/c' => true, + '/foo/ab/c' => false, ], ]; yield 'question mark in segment' => [ @@ -268,14 +271,14 @@ public static function provideQuestionMark(): Generator '/foo/aaa/bar' => false, '/foo/aaa/bar/' => false, '/foo/bar/zaa' => false, - '/foo/car/faa' => true, + '/foo/car/bar/faa' => true, ], ]; yield 'tailing question mark' => [ new FileMatcherPattern('/foo/?a?/bar/fa?'), [ - '/foo/car' => true, - '/foo/car/faa' => true, + '/foo/car' => false, + '/foo/car/bar/faa' => true, '/foo/ccr' => false, '/foo/bar/zaa' => false, ], From 00562d88c9d197cefab8dd81238961d036c69c68 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Mon, 10 Mar 2025 11:22:35 +0000 Subject: [PATCH 06/32] Implementing FileMatcher --- src/Util/FileMatcher.php | 84 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 95ed44d30fd..90f89b506fc 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -9,14 +9,8 @@ */ namespace PHPUnit\Util; +use InvalidArgumentException; use RuntimeException; -use const DIRECTORY_SEPARATOR; -use function basename; -use function dirname; -use function is_dir; -use function mkdir; -use function realpath; -use function str_starts_with; /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit @@ -26,6 +20,81 @@ final readonly class FileMatcher { public static function match(string $path, FileMatcherPattern $pattern): bool + { + self::assertIsAbsolute($path); + + $regex = self::toRegEx($pattern->path); + dump($pattern->path, $regex, $path); + + return preg_match($regex, $path) !== 0; + } + + /** + * Based on webmozart/glob + * + * @return string The regular expression for matching the glob. + */ + public static function toRegEx($glob, $flags = 0): string + { + self::assertIsAbsolute($glob); + + $inSquare = false; + $regex = ''; + $length = strlen($glob); + + for ($i = 0; $i < $length; ++$i) { + $c = $glob[$i]; + + switch ($c) { + case '?': + $regex .= '.'; + break; + + // the PHPUnit file iterator will match all + // files within a wildcard, not just until the + // next directory separator + case '*': + // if this is a ** but it is NOT preceded with `/` then + // it is not a globstar and just interpret it as a literal + if (($glob[$i + 1] ?? null) === '*') { + $regex .= '\*\*'; + $i++; + break; + } + $regex .= '.*'; + break; + case '/': + if (isset($glob[$i + 3]) && '**/' === $glob[$i + 1].$glob[$i + 2].$glob[$i + 3]) { + $regex .= '/([^/]+/)*'; + $i += 3; + break; + } + if ((!isset($glob[$i + 3])) && isset($glob[$i + 2]) && '**' === $glob[$i + 1].$glob[$i + 2]) { + $regex .= '.*'; + $i += 2; + break; + } + $regex .= '/'; + break; + default: + $regex .= $c; + break; + } + } + + if ($inSquare) { + throw new InvalidArgumentException(sprintf( + 'Invalid glob: missing ] in %s', + $glob + )); + } + + $regex .= '(/|$)'; + + return '{^'.$regex.'}'; + } + + private static function assertIsAbsolute(string $path): void { if (substr($path, 0, 1) !== '/') { throw new RuntimeException(sprintf( @@ -33,7 +102,6 @@ public static function match(string $path, FileMatcherPattern $pattern): bool $path )); } - return false; } } From 42250d92bfd4c65268d1fc5eeef90a3aed499d21 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Mon, 10 Mar 2025 11:52:40 +0000 Subject: [PATCH 07/32] Support character groups --- src/Util/FileMatcher.php | 39 ++++++++++- tests/unit/Util/FileMatcherTest.php | 104 ++++++++++++++++------------ tools/map | 4 ++ 3 files changed, 100 insertions(+), 47 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 90f89b506fc..34dac3fd752 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -24,7 +24,6 @@ public static function match(string $path, FileMatcherPattern $pattern): bool self::assertIsAbsolute($path); $regex = self::toRegEx($pattern->path); - dump($pattern->path, $regex, $path); return preg_match($regex, $path) !== 0; } @@ -46,9 +45,26 @@ public static function toRegEx($glob, $flags = 0): string $c = $glob[$i]; switch ($c) { + case '[': + $regex .= '['; + $inSquare = true; + if (isset($glob[$i + 1]) && '^' === $glob[$i + 1]) { + $regex .= '^'; + ++$i; + } + break; + case ']': + $regex .= $inSquare ? ']' : '\\]'; + $inSquare = false; + break; case '?': $regex .= '.'; break; + case '!': + if ($glob[$i - 1] === '[') { + $regex .= '^'; + break; + } // the PHPUnit file iterator will match all // files within a wildcard, not just until the @@ -76,6 +92,26 @@ public static function toRegEx($glob, $flags = 0): string } $regex .= '/'; break; + case '\\': + if (isset($glob[$i + 1])) { + switch ($glob[$i + 1]) { + case '*': + case '?': + case '[': + case ']': + case '\\': + $regex .= '\\'.$glob[$i + 1]; + ++$i; + break; + + default: + $regex .= '\\\\'; + } + } else { + $regex .= '\\\\'; + } + break; + default: $regex .= $c; break; @@ -104,4 +140,3 @@ private static function assertIsAbsolute(string $path): void } } } - diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 2d06dce9cc4..a59c7abff53 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -29,8 +29,12 @@ public function testExceptionIfPathIsNotAbsolute(): void #[DataProvider('provideQuestionMark')] #[DataProvider('provideCharacterGroup')] #[DataProvider('provideRelativePathSegments')] - public function testMatch(FileMatcherPattern $pattern, array $matchMap): void + public function testMatch(FileMatcherPattern $pattern, array $matchMap, ?string $skip = null): void { + if ($skip) { + self::markTestSkipped($skip); + } + self::assertMap($pattern, $matchMap); } @@ -175,8 +179,6 @@ public static function provideGlobstar(): Generator ], ]; - // TODO: this edge case - return; // PHPUnit will match ALL directories within `/foo` with `/foo/A**` // however it will NOT match anything with `/foo/Aa**` // @@ -193,6 +195,7 @@ public static function provideGlobstar(): Generator '/baz/emm/foo/bad' => false, '/baz/emm/foo/bad/boo' => false, ], + 'PHPUnit edge case', ]; } @@ -290,6 +293,10 @@ public static function provideQuestionMark(): Generator */ public static function provideCharacterGroup(): Generator { + // TODO: POSIX will interpret an unterminated [ group as a literal while + // Regex will crash -- we'd need to look ahead to see if the [ is + // terminated if we continue using Regex. + // yield 'unterminated char group' => [ new FileMatcherPattern('/[AB'), [ @@ -298,6 +305,7 @@ public static function provideCharacterGroup(): Generator '/[AB' => true, '/[AB/foo' => true, ], + 'Unterminated square bracket', ]; yield 'single char leaf' => [ new FileMatcherPattern('/[A]'), @@ -310,7 +318,7 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[B]/c'), [ '/a' => false, - '/a/B' => true, + '/a/B' => false, '/a/B/c' => true, '/a/Z/c' => false, ], @@ -319,7 +327,7 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[ABC]/c'), [ '/a' => false, - '/a/A' => true, + '/a/A' => false, '/a/B/c' => true, '/a/C/c' => true, '/a/Z/c' => false, @@ -338,7 +346,6 @@ public static function provideCharacterGroup(): Generator ]; // https://man7.org/linux/man-pages/man7/glob.7.html - // example from glob manpage yield 'square bracket in char group' => [ new FileMatcherPattern('/[][!]'), [ @@ -349,6 +356,7 @@ public static function provideCharacterGroup(): Generator '/a' => false, '/' => false, ], + 'Unterminated square bracket 2', ]; yield 'match ranges' => [ @@ -380,8 +388,10 @@ public static function provideCharacterGroup(): Generator yield 'dash in group' => [ new FileMatcherPattern('/a/[-]/c'), [ - '/a/-' => true, - '/a/-/fo' => true, + '/a/-' => false, + '/a/-/c' => true, + '/a/-/ca/d' => false, + '/a/-/c/da' => true, '/a/a/fo' => false, ], ]; @@ -390,12 +400,13 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[-a-c]/c'), [ '/a/a' => false, - '/a/-' => true, + '/a/-' => false, + '/a/-/c' => true, '/a/d' => false, '/a/-b/c' => false, - '/a/a/fo' => true, - '/a/c/fo' => true, - '/a/d/fo' => false, + '/a/a/c/fo' => true, + '/a/c/fo' => false, + '/a/d/c' => false, ], ]; @@ -403,13 +414,13 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[a-c-e-f]/c'), [ '/a/a' => false, - '/a/-' => true, - '/a/-/a' => true, - '/a/c/a' => true, - '/a/a/a' => true, - '/a/d/a' => false, - '/a/e/a' => true, - '/a/g/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, ], ]; @@ -418,13 +429,13 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[a-ce-f-]/c'), [ '/a/a' => false, - '/a/-' => true, - '/a/-/a' => true, - '/a/c/a' => true, - '/a/a/a' => true, - '/a/d/a' => false, - '/a/e/a' => true, - '/a/g/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, ], ]; @@ -433,30 +444,30 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/a/[!a]/c'), [ '/a/a' => false, - '/a/a/b' => false, - '/a/b/b' => true, - '/a/0/b' => true, - '/a/0a/b' => 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/b' => false, - '/a/b/b' => false, - '/a/c/b' => false, - '/a/d/b' => true, + '/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/b' => false, - '/a/b/b' => false, - '/a/c/b' => false, - '/a/d/b' => true, + '/a/a/c' => false, + '/a/b/c' => false, + '/a/c/c' => false, + '/a/d/c' => true, ] ]; @@ -466,7 +477,8 @@ public static function provideCharacterGroup(): Generator '/a/[!a-c]/c' => true, '/a/[!a-c]/c/d' => true, '/b/[!a-c]/c/d' => false, - ] + ], + 'Regex escaping', ]; // TODO: test all the character clases @@ -479,7 +491,8 @@ public static function provideCharacterGroup(): Generator '/a/1/c' => true, '/a/2/c' => true, '/b/!/c' => false, - ] + ], + 'Named character classes', ]; // TODO: all of these? @@ -492,7 +505,8 @@ public static function provideCharacterGroup(): Generator [ '/a/á/c' => true, '/a/a/c' => false, - ] + ], + 'Collating symbols', ]; // TODO: all of these? @@ -506,8 +520,8 @@ public static function provideCharacterGroup(): Generator [ '/a/á/c' => true, '/a/a/c' => true, - ] - + ], + 'Equaivalence class expressions', ]; } @@ -522,8 +536,8 @@ public static function provideRelativePathSegments(): Generator [ '/a/a/c' => true, '/a/b/c' => true, - ] - + ], + 'Relative path segments', ]; } /** diff --git a/tools/map b/tools/map index 797eb23f7cf..28c24c62664 100755 --- a/tools/map +++ b/tools/map @@ -1,6 +1,9 @@ #!/usr/bin/env php map(new Source( baseline: null, From dfff3ea2670f13800e78f945a64f61e46c526d34 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 09:29:10 +0000 Subject: [PATCH 08/32] Escaping unterminated openening brackets --- src/Util/FileMatcher.php | 32 ++++++++++++++++------------- tests/unit/Util/FileMatcherTest.php | 25 +++++++++++++++++----- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 34dac3fd752..83cf9e35ab9 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -37,30 +37,31 @@ public static function toRegEx($glob, $flags = 0): string { self::assertIsAbsolute($glob); - $inSquare = false; $regex = ''; $length = strlen($glob); + $brackets = []; + for ($i = 0; $i < $length; ++$i) { $c = $glob[$i]; switch ($c) { case '[': $regex .= '['; - $inSquare = true; - if (isset($glob[$i + 1]) && '^' === $glob[$i + 1]) { - $regex .= '^'; - ++$i; - } + $brackets[] = $i; break; case ']': - $regex .= $inSquare ? ']' : '\\]'; - $inSquare = false; + $regex .= ']'; + array_pop($brackets); break; case '?': $regex .= '.'; break; + case '-': + $regex .= '-'; + break; case '!': + // complementation/negation if ($glob[$i - 1] === '[') { $regex .= '^'; break; @@ -80,6 +81,7 @@ public static function toRegEx($glob, $flags = 0): string $regex .= '.*'; break; case '/': + // code could be refactored - handle globstars if (isset($glob[$i + 3]) && '**/' === $glob[$i + 1].$glob[$i + 2].$glob[$i + 3]) { $regex .= '/([^/]+/)*'; $i += 3; @@ -93,6 +95,8 @@ public static function toRegEx($glob, $flags = 0): string $regex .= '/'; break; case '\\': + // escape characters - this code is copy/pasted from webmozart/glob and + // needs revision if (isset($glob[$i + 1])) { switch ($glob[$i + 1]) { case '*': @@ -113,16 +117,16 @@ public static function toRegEx($glob, $flags = 0): string break; default: - $regex .= $c; + $regex .= preg_quote($c); break; } } - if ($inSquare) { - throw new InvalidArgumentException(sprintf( - 'Invalid glob: missing ] in %s', - $glob - )); + // escape unterminated brackets + $bracketOffset = 0; + foreach ($brackets as $offset) { + $regex = substr($regex, 0, $offset + $bracketOffset) . '\\' . substr($regex, $offset + $bracketOffset); + $bracketOffset++; } $regex .= '(/|$)'; diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index a59c7abff53..b4fc526998c 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -293,10 +293,6 @@ public static function provideQuestionMark(): Generator */ public static function provideCharacterGroup(): Generator { - // TODO: POSIX will interpret an unterminated [ group as a literal while - // Regex will crash -- we'd need to look ahead to see if the [ is - // terminated if we continue using Regex. - // yield 'unterminated char group' => [ new FileMatcherPattern('/[AB'), [ @@ -305,7 +301,26 @@ public static function provideCharacterGroup(): Generator '/[AB' => true, '/[AB/foo' => true, ], - 'Unterminated square bracket', + ]; + yield 'unterminated char group followed by char group' => [ + new FileMatcherPattern('/[AB[a-z]'), + [ + '/[' => false, + '/[Ac' => false, + '/[ABc' => true, + '/[ABc/foo' => true, + ], + ]; + yield 'multiple unterminated char groups followed by char group' => [ + new FileMatcherPattern('/[AB[CD[a-z]EF'), + [ + '/[' => false, + '/[Ac' => false, + '/[AB[C' => false, + '/[AB[CD' => false, + '/[AB[CDz' => false, + '/[AB[CDzEF' => true, + ], ]; yield 'single char leaf' => [ new FileMatcherPattern('/[A]'), From 596603834ff9b266962e5c657c0545803e0d64e2 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 09:44:24 +0000 Subject: [PATCH 09/32] Update --- src/Util/FileMatcher.php | 5 +++-- tests/unit/Util/FileMatcherTest.php | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 83cf9e35ab9..f5622c509bd 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -61,8 +61,8 @@ public static function toRegEx($glob, $flags = 0): string $regex .= '-'; break; case '!': - // complementation/negation - if ($glob[$i - 1] === '[') { + // complementation/negation: taking into account escaped square brackets + if ($glob[$i - 1] === '[' && ($glob[$i - 2] !== '\\' || ($glob[$i -2] === '\\' && $glob[$i - 3] === '\\'))) { $regex .= '^'; break; } @@ -131,6 +131,7 @@ public static function toRegEx($glob, $flags = 0): string $regex .= '(/|$)'; + dump($regex); return '{^'.$regex.'}'; } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index b4fc526998c..0871ca5c5b6 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -493,7 +493,12 @@ public static function provideCharacterGroup(): Generator '/a/[!a-c]/c/d' => true, '/b/[!a-c]/c/d' => false, ], - 'Regex escaping', + ]; + yield 'literal backslash neagted group' => [ + new FileMatcherPattern('/a/\\\[!a-c]/c'), + [ + '/a/\\d/c' => true, + ], ]; // TODO: test all the character clases From 5eb851a63f38c3267fb2bbe911f985f4000394a5 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:18:58 +0000 Subject: [PATCH 10/32] Tokenizing --- src/Util/FileMatcher.php | 183 +++++++++++++++++++-------------------- 1 file changed, 91 insertions(+), 92 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index f5622c509bd..ab2d47ec4fc 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -9,16 +9,29 @@ */ namespace PHPUnit\Util; -use InvalidArgumentException; +use PHPUnit\Exception; use RuntimeException; /** * @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 T_BRACKET_OPEN = 'bracket_open'; + private const T_BRACKET_CLOSE = 'bracket_close'; + private const T_BANG = 'bang'; + private const T_HYPHEN = 'hyphen'; + private const T_ASTERIX = 'asterix'; + private const T_SLASH = 'slash'; + private const T_BACKSLASH = 'backslash'; + private const T_CHAR = 'char'; + private const T_GLOBSTAR = 'globstar'; + private const T_QUERY = 'query'; + + public static function match(string $path, FileMatcherPattern $pattern): bool { self::assertIsAbsolute($path); @@ -37,101 +50,27 @@ public static function toRegEx($glob, $flags = 0): string { self::assertIsAbsolute($glob); - $regex = ''; - $length = strlen($glob); - - $brackets = []; + $tokens = self::tokenize($glob); - for ($i = 0; $i < $length; ++$i) { - $c = $glob[$i]; - - switch ($c) { - case '[': - $regex .= '['; - $brackets[] = $i; - break; - case ']': - $regex .= ']'; - array_pop($brackets); - break; - case '?': - $regex .= '.'; - break; - case '-': - $regex .= '-'; - break; - case '!': - // complementation/negation: taking into account escaped square brackets - if ($glob[$i - 1] === '[' && ($glob[$i - 2] !== '\\' || ($glob[$i -2] === '\\' && $glob[$i - 3] === '\\'))) { - $regex .= '^'; - break; - } - - // the PHPUnit file iterator will match all - // files within a wildcard, not just until the - // next directory separator - case '*': - // if this is a ** but it is NOT preceded with `/` then - // it is not a globstar and just interpret it as a literal - if (($glob[$i + 1] ?? null) === '*') { - $regex .= '\*\*'; - $i++; - break; - } - $regex .= '.*'; - break; - case '/': - // code could be refactored - handle globstars - if (isset($glob[$i + 3]) && '**/' === $glob[$i + 1].$glob[$i + 2].$glob[$i + 3]) { - $regex .= '/([^/]+/)*'; - $i += 3; - break; - } - if ((!isset($glob[$i + 3])) && isset($glob[$i + 2]) && '**' === $glob[$i + 1].$glob[$i + 2]) { - $regex .= '.*'; - $i += 2; - break; - } - $regex .= '/'; - break; - case '\\': - // escape characters - this code is copy/pasted from webmozart/glob and - // needs revision - if (isset($glob[$i + 1])) { - switch ($glob[$i + 1]) { - case '*': - case '?': - case '[': - case ']': - case '\\': - $regex .= '\\'.$glob[$i + 1]; - ++$i; - break; - - default: - $regex .= '\\\\'; - } - } else { - $regex .= '\\\\'; - } - break; - - default: - $regex .= preg_quote($c); - break; - } - } + $regex = ''; - // escape unterminated brackets - $bracketOffset = 0; - foreach ($brackets as $offset) { - $regex = substr($regex, 0, $offset + $bracketOffset) . '\\' . substr($regex, $offset + $bracketOffset); - $bracketOffset++; + foreach ($tokens as $token) { + $type = $token[0]; + $regex .= match ($type) { + // literal char + self::T_CHAR => $token[1] ?? throw new Exception('Expected char token to have a value'), + + // literal directory separator + self::T_SLASH => '/', + self::T_QUERY => '.', + + // match any segment up until the next directory separator + self::T_ASTERIX => '[^/]*', + self::T_GLOBSTAR => '.*', + default => '', + }; } - $regex .= '(/|$)'; - - dump($regex); return '{^'.$regex.'}'; } @@ -144,4 +83,64 @@ private static function assertIsAbsolute(string $path): void )); } } + + /** + * @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], + default => [self::T_CHAR, $c], + }; + } + + return self::processTokens($tokens); + } + + /** + * @param list $tokens + * @return list + */ + private static function processTokens(array $tokens): array + { + $resolved = []; + $escaped = false; + for ($offset = 0; $offset < count($tokens); $offset++) { + [$type, $char] = $tokens[$offset]; + + if ($type === self::T_BACKSLASH && false === $escaped) { + $escaped = true; + continue; + } + + if ($escaped === true) { + $resolved[] = [self::T_CHAR, $char]; + continue; + } + + if ($type === self::T_ASTERIX && ($tokens[$offset + 1] ?? null) === self::T_ASTERIX) { + $offset++; + $resolved[] = [self::T_GLOBSTAR, '**']; + continue; + } + + $resolved[] = [$type, $char]; + } + return $resolved; + } } From 7b1005102c234d36f05385a1385611fe80837de2 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:46:46 +0000 Subject: [PATCH 11/32] Progressing --- src/Util/FileMatcher.php | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index ab2d47ec4fc..adf40650c3d 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -28,8 +28,9 @@ private const T_SLASH = 'slash'; private const T_BACKSLASH = 'backslash'; private const T_CHAR = 'char'; - private const T_GLOBSTAR = 'globstar'; + private const T_GREEDY_GLOBSTAR = 'greedy_globstar'; private const T_QUERY = 'query'; + private const T_GLOBSTAR = 'globstar'; public static function match(string $path, FileMatcherPattern $pattern): bool @@ -58,7 +59,7 @@ public static function toRegEx($glob, $flags = 0): string $type = $token[0]; $regex .= match ($type) { // literal char - self::T_CHAR => $token[1] ?? throw new Exception('Expected char token to have a value'), + self::T_CHAR => preg_quote($token[1]), // literal directory separator self::T_SLASH => '/', @@ -66,10 +67,14 @@ public static function toRegEx($glob, $flags = 0): string // match any segment up until the next directory separator self::T_ASTERIX => '[^/]*', - self::T_GLOBSTAR => '.*', + self::T_GREEDY_GLOBSTAR => '.*', + self::T_GLOBSTAR => '/([^/]+/)*', default => '', }; } + $regex .= '(/|$)'; + dump($tokens); + dump($regex); return '{^'.$regex.'}'; } @@ -133,9 +138,33 @@ private static function processTokens(array $tokens): array continue; } - if ($type === self::T_ASTERIX && ($tokens[$offset + 1] ?? null) === self::T_ASTERIX) { - $offset++; + // normal globstar + if ( + $type === self::T_SLASH && + ($tokens[$offset + 1][0] ?? null) === 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 `*` in addition to the slash + $offset += 3; + continue; + } + + // greedy globstar (trailing?) + 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; + } + + if ($type === self::T_ASTERIX && ($tokens[$offset + 1][0] ?? null) === self::T_ASTERIX) { + $resolved[] = [self::T_CHAR, $char]; + $resolved[] = [self::T_CHAR, $char]; continue; } From ca252ad5274ee9e956eeae4b721d173433cb864d Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:48:10 +0000 Subject: [PATCH 12/32] Failing unterminated --- src/Util/FileMatcher.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index adf40650c3d..2bc675dd710 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -69,6 +69,9 @@ public static function toRegEx($glob, $flags = 0): string self::T_ASTERIX => '[^/]*', self::T_GREEDY_GLOBSTAR => '.*', self::T_GLOBSTAR => '/([^/]+/)*', + self::T_BRACKET_OPEN => '[', + self::T_BRACKET_CLOSE => ']', + self::T_HYPHEN => '-', default => '', }; } From 1a984fd1462fc1e20f02ef55628b97237a9a1760 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:50:53 +0000 Subject: [PATCH 13/32] Unterminated bracket --- src/Util/FileMatcher.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 2bc675dd710..4347e1eb001 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -128,6 +128,7 @@ private static function processTokens(array $tokens): array { $resolved = []; $escaped = false; + $brackets = []; for ($offset = 0; $offset < count($tokens); $offset++) { [$type, $char] = $tokens[$offset]; @@ -171,8 +172,18 @@ private static function processTokens(array $tokens): array continue; } + if ($type === self::T_BRACKET_OPEN) { + $brackets[] = $offset; + } + if ($type === self::T_BRACKET_CLOSE) { + array_pop($brackets); + } + $resolved[] = [$type, $char]; } + foreach ($brackets as $unterminatedBracket) { + $resolved[$unterminatedBracket] = [self::T_CHAR, '[']; + } return $resolved; } } From 05a10874dbadda57f9786748c79f670a5024b595 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:55:30 +0000 Subject: [PATCH 14/32] Complementation --- src/Util/FileMatcher.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 4347e1eb001..8d2a202d648 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -64,6 +64,7 @@ public static function toRegEx($glob, $flags = 0): string // literal directory separator self::T_SLASH => '/', self::T_QUERY => '.', + self::T_BANG => '^', // match any segment up until the next directory separator self::T_ASTERIX => '[^/]*', @@ -154,7 +155,8 @@ private static function processTokens(array $tokens): array continue; } - // greedy globstar (trailing?) + // 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 @@ -172,18 +174,32 @@ private static function processTokens(array $tokens): array 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) - 1]) && $resolved[array_key_last($resolved) - 1][0] === self::T_BRACKET_OPEN) { + $resolved[] = [self::T_BANG, '!']; + continue; + } + if ($type === self::T_BRACKET_OPEN) { $brackets[] = $offset; + $resolved[] = [$type, $char]; + continue; } + if ($type === self::T_BRACKET_CLOSE) { 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; } } From 05e58c47ffa6a9867c415016bb15201400dcb62c Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 10:57:07 +0000 Subject: [PATCH 15/32] Negated group --- src/Util/FileMatcher.php | 1 + tests/unit/Util/FileMatcherTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 8d2a202d648..88d5472a873 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -140,6 +140,7 @@ private static function processTokens(array $tokens): array if ($escaped === true) { $resolved[] = [self::T_CHAR, $char]; + $escaped = false; continue; } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 0871ca5c5b6..8b378ac7ef3 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -494,7 +494,7 @@ public static function provideCharacterGroup(): Generator '/b/[!a-c]/c/d' => false, ], ]; - yield 'literal backslash neagted group' => [ + yield 'literal backslash negated group' => [ new FileMatcherPattern('/a/\\\[!a-c]/c'), [ '/a/\\d/c' => true, From f3837cc3f84c019aff65c1b0822c8d0e727d201c Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 11:01:30 +0000 Subject: [PATCH 16/32] Fix complementation --- src/Util/FileMatcher.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 88d5472a873..ebcf0596592 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -176,11 +176,16 @@ private static function processTokens(array $tokens): array } // 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) - 1]) && $resolved[array_key_last($resolved) - 1][0] === self::T_BRACKET_OPEN) { + 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 ($type === self::T_BANG) { + $resolved[] = [self::T_CHAR, $char]; + continue; + } + if ($type === self::T_BRACKET_OPEN) { $brackets[] = $offset; $resolved[] = [$type, $char]; From 47efb1fb8d714e7b273aca95579403d3c01a1d2d Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 11:14:20 +0000 Subject: [PATCH 17/32] Ssquare --- src/Util/FileMatcher.php | 13 +++++++++++-- tests/unit/Util/FileMatcherTest.php | 1 - 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index ebcf0596592..529da7817d9 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -39,7 +39,8 @@ public static function match(string $path, FileMatcherPattern $pattern): bool $regex = self::toRegEx($pattern->path); - return preg_match($regex, $path) !== 0; + $result = preg_match($regex, $path) !== 0; + return $result; } /** @@ -181,14 +182,22 @@ private static function processTokens(array $tokens): array 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; } + if ($type === self::T_BRACKET_OPEN && $tokens[$offset + 1][0] === self::T_BRACKET_CLOSE) { + $resolved[] = [self::T_BRACKET_OPEN, $char]; + $brackets[] = array_key_last($resolved); + $resolved[] = [self::T_CHAR, $char]; + continue; + } if ($type === self::T_BRACKET_OPEN) { - $brackets[] = $offset; $resolved[] = [$type, $char]; + $brackets[] = array_key_last($resolved); continue; } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 8b378ac7ef3..455f8c03ed4 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -371,7 +371,6 @@ public static function provideCharacterGroup(): Generator '/a' => false, '/' => false, ], - 'Unterminated square bracket 2', ]; yield 'match ranges' => [ From 633eb7cdb656f8ffe82bb64f34a359022e63efbd Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 11:46:17 +0000 Subject: [PATCH 18/32] Skip test --- src/Util/FileMatcher.php | 1 + tests/unit/Util/FileMatcherTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 529da7817d9..5d4cdee0f31 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -131,6 +131,7 @@ private static function processTokens(array $tokens): array $resolved = []; $escaped = false; $brackets = []; + for ($offset = 0; $offset < count($tokens); $offset++) { [$type, $char] = $tokens[$offset]; diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 455f8c03ed4..8079927bef5 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -371,6 +371,7 @@ public static function provideCharacterGroup(): Generator '/a' => false, '/' => false, ], + 'This test fails because `[` should be interpreted a literal', ]; yield 'match ranges' => [ From f9a8bc0bdf08db4bbd9915a8e69b3010d26c0667 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 11:46:47 +0000 Subject: [PATCH 19/32] Fix CS --- src/Util/FileMatcher.php | 98 ++++---- src/Util/FileMatcherPattern.php | 12 +- tests/unit/Util/FileMatcherTest.php | 340 +++++++++++++++------------- 3 files changed, 250 insertions(+), 200 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 5d4cdee0f31..11135d3f6d5 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -9,29 +9,36 @@ */ namespace PHPUnit\Util; -use PHPUnit\Exception; +use function array_key_last; +use function array_pop; +use function count; +use function preg_match; +use function preg_quote; +use function sprintf; +use function strlen; +use function substr; use RuntimeException; /** * @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 T_BRACKET_OPEN = 'bracket_open'; - private const T_BRACKET_CLOSE = 'bracket_close'; - private const T_BANG = 'bang'; - private const T_HYPHEN = 'hyphen'; - private const T_ASTERIX = 'asterix'; - private const T_SLASH = 'slash'; - private const T_BACKSLASH = 'backslash'; - private const T_CHAR = 'char'; + private const T_BRACKET_OPEN = 'bracket_open'; + private const T_BRACKET_CLOSE = 'bracket_close'; + private const T_BANG = 'bang'; + private const T_HYPHEN = 'hyphen'; + private const T_ASTERIX = 'asterix'; + private const T_SLASH = 'slash'; + private const T_BACKSLASH = 'backslash'; + private const T_CHAR = 'char'; private const T_GREEDY_GLOBSTAR = 'greedy_globstar'; - private const T_QUERY = 'query'; - private const T_GLOBSTAR = 'globstar'; - + private const T_QUERY = 'query'; + private const T_GLOBSTAR = 'globstar'; public static function match(string $path, FileMatcherPattern $pattern): bool { @@ -39,14 +46,13 @@ public static function match(string $path, FileMatcherPattern $pattern): bool $regex = self::toRegEx($pattern->path); - $result = preg_match($regex, $path) !== 0; - return $result; + return preg_match($regex, $path) !== 0; } /** - * Based on webmozart/glob + * Based on webmozart/glob. * - * @return string The regular expression for matching the glob. + * @return string the regular expression for matching the glob */ public static function toRegEx($glob, $flags = 0): string { @@ -65,23 +71,23 @@ public static function toRegEx($glob, $flags = 0): string // literal directory separator self::T_SLASH => '/', self::T_QUERY => '.', - self::T_BANG => '^', + self::T_BANG => '^', // match any segment up until the next directory separator - self::T_ASTERIX => '[^/]*', + self::T_ASTERIX => '[^/]*', self::T_GREEDY_GLOBSTAR => '.*', - self::T_GLOBSTAR => '/([^/]+/)*', - self::T_BRACKET_OPEN => '[', - self::T_BRACKET_CLOSE => ']', - self::T_HYPHEN => '-', - default => '', + self::T_GLOBSTAR => '/([^/]+/)*', + self::T_BRACKET_OPEN => '[', + self::T_BRACKET_CLOSE => ']', + self::T_HYPHEN => '-', + default => '', }; } $regex .= '(/|$)'; dump($tokens); dump($regex); - return '{^'.$regex.'}'; + return '{^' . $regex . '}'; } private static function assertIsAbsolute(string $path): void @@ -89,7 +95,7 @@ private static function assertIsAbsolute(string $path): void if (substr($path, 0, 1) !== '/') { throw new RuntimeException(sprintf( 'Path "%s" must be absolute', - $path + $path, )); } } @@ -100,21 +106,21 @@ private static function assertIsAbsolute(string $path): void private static function tokenize(string $glob): array { $length = strlen($glob); - + $tokens = []; - - for ($i = 0; $i < $length; ++$i) { + + 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_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], default => [self::T_CHAR, $c], }; } @@ -124,12 +130,13 @@ private static function tokenize(string $glob): array /** * @param list $tokens + * * @return list */ private static function processTokens(array $tokens): array { $resolved = []; - $escaped = false; + $escaped = false; $brackets = []; for ($offset = 0; $offset < count($tokens); $offset++) { @@ -137,12 +144,14 @@ private static function processTokens(array $tokens): array if ($type === self::T_BACKSLASH && false === $escaped) { $escaped = true; + continue; } if ($escaped === true) { $resolved[] = [self::T_CHAR, $char]; - $escaped = false; + $escaped = false; + continue; } @@ -155,10 +164,11 @@ private static function processTokens(array $tokens): array // we eat the two `*` in addition to the slash $offset += 3; + continue; } - // greedy globstar (trailing?) + // 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 && @@ -168,18 +178,21 @@ private static function processTokens(array $tokens): array // we eat the two `*` in addition to the slash $offset += 2; + continue; } 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; } @@ -187,6 +200,7 @@ private static function processTokens(array $tokens): array // to a literal char if ($type === self::T_BANG) { $resolved[] = [self::T_CHAR, $char]; + continue; } @@ -194,17 +208,21 @@ private static function processTokens(array $tokens): array $resolved[] = [self::T_BRACKET_OPEN, $char]; $brackets[] = array_key_last($resolved); $resolved[] = [self::T_CHAR, $char]; + continue; } + if ($type === self::T_BRACKET_OPEN) { $resolved[] = [$type, $char]; $brackets[] = array_key_last($resolved); + continue; } if ($type === self::T_BRACKET_CLOSE) { array_pop($brackets); $resolved[] = [$type, $char]; + continue; } diff --git a/src/Util/FileMatcherPattern.php b/src/Util/FileMatcherPattern.php index 6b5ab31dc27..9ecd4ef16d4 100644 --- a/src/Util/FileMatcherPattern.php +++ b/src/Util/FileMatcherPattern.php @@ -1,5 +1,12 @@ - + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace PHPUnit\Util; class FileMatcherPattern @@ -7,5 +14,4 @@ class FileMatcherPattern public function __construct(public string $path) { } - } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 8079927bef5..7a6bbd8e0e4 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -1,7 +1,15 @@ - + * + * 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; @@ -13,31 +21,6 @@ #[Small] class FileMatcherTest extends TestCase { - public function testExceptionIfPathIsNotAbsolute(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Path "foo/bar" must be absolute'); - FileMatcher::match('foo/bar', new FileMatcherPattern('')); - } - - /** - * @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) { - self::markTestSkipped($skip); - } - - self::assertMap($pattern, $matchMap); - } - /** * @return Generator}> */ @@ -54,9 +37,9 @@ public static function provideMatch(): Generator yield 'directory' => [ new FileMatcherPattern('/path/to'), [ - '/path/to' => true, + '/path/to' => true, '/path/to/example/Foo.php' => true, - '/path/foo/Bar.php' => false, + '/path/foo/Bar.php' => false, ], ]; } @@ -69,65 +52,66 @@ 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/foo/bar' => true, + '/path/foo/baz' => true, + '/path/baz.php' => true, + '/path/foo/baz/boo.php' => true, '/path/example/file.php' => true, - '/' => false, + '/' => false, ], ]; yield 'leaf directory wildcard' => [ new FileMatcherPattern('/path/*'), [ - '/path/foo/bar' => true, - '/path/foo/baz' => true, - '/path/foo/baz/boo.php' => true, + '/path/foo/bar' => true, + '/path/foo/baz' => true, + '/path/foo/baz/boo.php' => true, '/path/example/file.php' => true, - '/' => false, + '/' => false, ], - ]; + ]; + yield 'segment directory wildcard' => [ new FileMatcherPattern('/path/*/bar'), [ - '/path/foo/bar' => true, - '/path/foo/baz' => false, + '/path/foo/bar' => true, + '/path/foo/baz' => false, '/path/foo/bar/boo.php' => true, - '/foo/bar/file.php' => false, + '/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' => true, '/path/zz/example/aa/bar/foo' => true, - '/path/example/aa/bar/foo' => false, - '/path/zz/example/bb/foo' => false, + '/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/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/foo/bar' => true, + '/path/faa/bar' => true, + '/path/foo/baz' => false, + '/path/boo' => false, '/path/boo/example/file.php' => false, ], - ]; + ]; } /** @@ -138,20 +122,20 @@ public static function provideGlobstar(): Generator yield 'leaf globstar at root' => [ new FileMatcherPattern('/**'), [ - '/foo' => true, + '/foo' => true, '/foo/bar' => true, - '/' => true, // matches zero or more + '/' => true, // matches zero or more ], ]; yield 'leaf globstar' => [ new FileMatcherPattern('/foo/**'), [ - '/foo' => true, - '/foo/foo' => true, + '/foo' => true, + '/foo/foo' => true, '/foo/foo/baz.php' => true, - '/bar/foo' => false, - '/bar/foo/baz' => false, + '/bar/foo' => false, + '/bar/foo/baz' => false, ], ]; @@ -159,23 +143,23 @@ public static function provideGlobstar(): Generator yield 'partial leaf globstar' => [ new FileMatcherPattern('/foo/emm**'), [ - '/foo/emmer' => false, - '/foo/emm' => false, + '/foo/emmer' => false, + '/foo/emm' => false, '/foo/emm/bar' => false, - '/' => false, + '/' => false, ], ]; yield 'segment globstar' => [ new FileMatcherPattern('/foo/emm/**/bar'), [ - '/foo/emm/bar' => true, - '/foo/emm/foo/bar' => true, + '/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, + '/baz/emm/foo/bar' => false, + '/foo/emm/barfoo' => false, + '/foo/emm/' => false, + '/foo/emm' => false, ], ]; @@ -186,13 +170,13 @@ public static function provideGlobstar(): Generator 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/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' => false, '/baz/emm/foo/bad/boo' => false, ], 'PHPUnit edge case', @@ -207,58 +191,63 @@ public static function provideQuestionMark(): Generator yield 'question mark at root' => [ new FileMatcherPattern('/?'), [ - '/' => false, - '/f' => true, - '/foo' => false, - '/f/emm/foo/bar' => true, + '/' => 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' => 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, + '/' => 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, + '/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' => 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, + '/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?'), [ @@ -266,24 +255,26 @@ public static function provideQuestionMark(): Generator '/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' => 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' => false, '/foo/car/bar/faa' => true, - '/foo/ccr' => false, - '/foo/bar/zaa' => false, + '/foo/ccr' => false, + '/foo/bar/zaa' => false, ], ]; } @@ -296,32 +287,35 @@ public static function provideCharacterGroup(): Generator yield 'unterminated char group' => [ new FileMatcherPattern('/[AB'), [ - '/[' => false, - '/[A' => false, - '/[AB' => true, + '/[' => false, + '/[A' => false, + '/[AB' => true, '/[AB/foo' => true, ], ]; + yield 'unterminated char group followed by char group' => [ new FileMatcherPattern('/[AB[a-z]'), [ - '/[' => false, - '/[Ac' => false, - '/[ABc' => true, + '/[' => false, + '/[Ac' => false, + '/[ABc' => true, '/[ABc/foo' => true, ], ]; + yield 'multiple unterminated char groups followed by char group' => [ new FileMatcherPattern('/[AB[CD[a-z]EF'), [ - '/[' => false, - '/[Ac' => false, - '/[AB[C' => false, - '/[AB[CD' => false, - '/[AB[CDz' => false, + '/[' => false, + '/[Ac' => false, + '/[AB[C' => false, + '/[AB[CD' => false, + '/[AB[CDz' => false, '/[AB[CDzEF' => true, ], ]; + yield 'single char leaf' => [ new FileMatcherPattern('/[A]'), [ @@ -329,24 +323,26 @@ public static function provideCharacterGroup(): Generator '/B' => false, ], ]; + yield 'single char segment' => [ new FileMatcherPattern('/a/[B]/c'), [ - '/a' => false, - '/a/B' => false, + '/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' => false, + '/a/A' => false, + '/a/B/c' => true, + '/a/C/c' => true, + '/a/Z/c' => false, + '/a/Za/c' => false, '/a/Aaa/c' => false, ], ]; @@ -354,7 +350,7 @@ public static function provideCharacterGroup(): Generator yield 'matching is case sensitive' => [ new FileMatcherPattern('/a/[ABC]/c'), [ - '/a/a' => false, + '/a/a' => false, '/a/b/c' => false, '/a/c/c' => false, ], @@ -365,11 +361,11 @@ public static function provideCharacterGroup(): Generator new FileMatcherPattern('/[][!]'), [ '/[hello' => true, - '/[' => true, - '/!' => true, - '/!bang' => true, - '/a' => false, - '/' => false, + '/[' => true, + '/!' => true, + '/!bang' => true, + '/a' => false, + '/' => false, ], 'This test fails because `[` should be interpreted a literal', ]; @@ -377,7 +373,7 @@ public static function provideCharacterGroup(): Generator yield 'match ranges' => [ new FileMatcherPattern('/a/[a-c]/c'), [ - '/a/a' => false, + '/a/a' => false, '/a/z/c' => false, '/a/b/c' => true, '/a/c/c' => true, @@ -389,7 +385,7 @@ public static function provideCharacterGroup(): Generator yield 'multiple match ranges' => [ new FileMatcherPattern('/a/[a-c0-8]/c'), [ - '/a/a' => false, + '/a/a' => false, '/a/0/c' => true, '/a/2/c' => true, '/a/8/c' => true, @@ -403,32 +399,32 @@ public static function provideCharacterGroup(): Generator yield 'dash in group' => [ new FileMatcherPattern('/a/[-]/c'), [ - '/a/-' => false, - '/a/-/c' => true, + '/a/-' => false, + '/a/-/c' => true, '/a/-/ca/d' => false, '/a/-/c/da' => true, - '/a/a/fo' => false, + '/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' => 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, + '/a/c/fo' => false, + '/a/d/c' => false, ], ]; yield 'range infix dash' => [ new FileMatcherPattern('/a/[a-c-e-f]/c'), [ - '/a/a' => false, + '/a/a' => false, '/a/-/c' => true, '/a/-/a' => false, '/a/c/c' => true, @@ -443,7 +439,7 @@ public static function provideCharacterGroup(): Generator yield 'range suffix dash' => [ new FileMatcherPattern('/a/[a-ce-f-]/c'), [ - '/a/a' => false, + '/a/a' => false, '/a/-/c' => true, '/a/-/c' => true, '/a/c/c' => true, @@ -458,12 +454,12 @@ public static function provideCharacterGroup(): Generator 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/a' => false, + '/a/a/c' => false, + '/a/b/c' => true, + '/a/0/c' => true, '/a/0a/c' => false, - ] + ], ]; yield 'complementation multi char' => [ @@ -473,7 +469,7 @@ public static function provideCharacterGroup(): Generator '/a/b/c' => false, '/a/c/c' => false, '/a/d/c' => true, - ] + ], ]; yield 'complementation range' => [ @@ -483,17 +479,18 @@ public static function provideCharacterGroup(): Generator '/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' => true, '/a/[!a-c]/c/d' => true, '/b/[!a-c]/c/d' => false, ], ]; + yield 'literal backslash negated group' => [ new FileMatcherPattern('/a/\\\[!a-c]/c'), [ @@ -546,7 +543,8 @@ public static function provideCharacterGroup(): Generator } /** - * TODO: expand this + * TODO: expand this. + * * @return Generator}> */ public static function provideRelativePathSegments(): Generator @@ -560,6 +558,32 @@ public static function provideRelativePathSegments(): Generator 'Relative path segments', ]; } + + public function testExceptionIfPathIsNotAbsolute(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path "foo/bar" must be absolute'); + FileMatcher::match('foo/bar', new FileMatcherPattern('')); + } + + /** + * @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); + } + /** * @param array $matchMap */ @@ -567,15 +591,17 @@ private static function assertMap(FileMatcherPattern $pattern, array $matchMap): { foreach ($matchMap as $candidate => $shouldMatch) { $matches = FileMatcher::match($candidate, $pattern); + 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 + $candidate, )); } } From 901c050ee246ccb3106839ac5a8b4c4bdea99f5c Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 22:00:13 +0000 Subject: [PATCH 20/32] Fix nested brackets --- src/Util/FileMatcher.php | 17 ++++++++++++++--- tests/unit/Util/FileMatcherTest.php | 25 +++++++++++++------------ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 11135d3f6d5..ec75fbac5a9 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -137,6 +137,7 @@ private static function processTokens(array $tokens): array { $resolved = []; $escaped = false; + $bracketOpen = false; $brackets = []; for ($offset = 0; $offset < count($tokens); $offset++) { @@ -205,14 +206,24 @@ private static function processTokens(array $tokens): array } if ($type === self::T_BRACKET_OPEN && $tokens[$offset + 1][0] === self::T_BRACKET_CLOSE) { - $resolved[] = [self::T_BRACKET_OPEN, $char]; + $bracketOpen = true; + $resolved[] = [self::T_BRACKET_OPEN, '[']; $brackets[] = array_key_last($resolved); - $resolved[] = [self::T_CHAR, $char]; + $resolved[] = [self::T_CHAR, ']']; + $offset += 1; + + continue; + } + if ($bracketOpen === true && $type === self::T_BRACKET_OPEN) { + // if bracket is already open, interpret everything as a + // literal char + $resolved[] = [self::T_CHAR, $char]; continue; } - if ($type === self::T_BRACKET_OPEN) { + if ($bracketOpen === false && $type === self::T_BRACKET_OPEN) { + $bracketOpen = true; $resolved[] = [$type, $char]; $brackets[] = array_key_last($resolved); diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 7a6bbd8e0e4..78ec207922c 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -297,22 +297,24 @@ public static function provideCharacterGroup(): Generator yield 'unterminated char group followed by char group' => [ new FileMatcherPattern('/[AB[a-z]'), [ - '/[' => false, - '/[Ac' => false, - '/[ABc' => true, - '/[ABc/foo' => true, + '/[' => 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'), [ - '/[' => false, - '/[Ac' => false, - '/[AB[C' => false, - '/[AB[CD' => false, - '/[AB[CDz' => false, - '/[AB[CDzEF' => true, + '/[EF' => true, + '/AEF' => true, + '/[EF' => true, + '/DEF' => true, + '/EEF' => false, ], ]; @@ -358,7 +360,7 @@ public static function provideCharacterGroup(): Generator // https://man7.org/linux/man-pages/man7/glob.7.html yield 'square bracket in char group' => [ - new FileMatcherPattern('/[][!]'), + new FileMatcherPattern('/[][!]*'), [ '/[hello' => true, '/[' => true, @@ -367,7 +369,6 @@ public static function provideCharacterGroup(): Generator '/a' => false, '/' => false, ], - 'This test fails because `[` should be interpreted a literal', ]; yield 'match ranges' => [ From 8048d0ec42cbf3f2b76eb9d950f2a2eeec48d012 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 22:34:16 +0000 Subject: [PATCH 21/32] Support char classes --- src/Util/FileMatcher.php | 49 +++++++++++++++++++------ tests/unit/Util/FileMatcherTest.php | 56 ++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index ec75fbac5a9..e596a0de216 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -12,6 +12,7 @@ use function array_key_last; use function array_pop; use function count; +use function ctype_alpha; use function preg_match; use function preg_quote; use function sprintf; @@ -39,6 +40,8 @@ private const T_GREEDY_GLOBSTAR = 'greedy_globstar'; private const T_QUERY = 'query'; private const T_GLOBSTAR = 'globstar'; + private const T_COLON = 'colon'; + private const T_CHAR_CLASS = 'char_class'; public static function match(string $path, FileMatcherPattern $pattern): bool { @@ -80,6 +83,7 @@ public static function toRegEx($glob, $flags = 0): string self::T_BRACKET_OPEN => '[', self::T_BRACKET_CLOSE => ']', self::T_HYPHEN => '-', + self::T_CHAR_CLASS => '[:' . $token[1] . ':]', default => '', }; } @@ -121,6 +125,7 @@ private static function tokenize(string $glob): array '*' => [self::T_ASTERIX, $c], '/' => [self::T_SLASH, $c], '\\' => [self::T_BACKSLASH, $c], + ':' => [self::T_COLON, $c], default => [self::T_CHAR, $c], }; } @@ -135,13 +140,14 @@ private static function tokenize(string $glob): array */ private static function processTokens(array $tokens): array { - $resolved = []; - $escaped = false; + $resolved = []; + $escaped = false; $bracketOpen = false; - $brackets = []; + $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) { $escaped = true; @@ -205,27 +211,50 @@ private static function processTokens(array $tokens): array continue; } - if ($type === self::T_BRACKET_OPEN && $tokens[$offset + 1][0] === self::T_BRACKET_CLOSE) { + 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 += 1; + $resolved[] = [self::T_BRACKET_OPEN, '[']; + $brackets[] = array_key_last($resolved); + $resolved[] = [self::T_CHAR, ']']; + $offset++; continue; } + 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 ($bracketOpen === true && $type === self::T_BRACKET_OPEN) { // if bracket is already open, interpret everything as a // literal char $resolved[] = [self::T_CHAR, $char]; + continue; } if ($bracketOpen === false && $type === self::T_BRACKET_OPEN) { $bracketOpen = true; - $resolved[] = [$type, $char]; - $brackets[] = array_key_last($resolved); + $resolved[] = [$type, $char]; + $brackets[] = array_key_last($resolved); continue; } diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 78ec207922c..a5e1fdd0567 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -302,7 +302,7 @@ public static function provideCharacterGroup(): Generator '/A' => true, '/B' => true, - '/Z' => false, + '/Z' => false, '/[c' => false, ], ]; @@ -503,17 +503,47 @@ public static function provideCharacterGroup(): Generator // [:alnum:] [:alpha:] [:blank:] [:cntrl:] // [:digit:] [:graph:] [:lower:] [:print:] // [:punct:] [:space:] [:upper:] [:xdigit:] - yield 'character class...' => [ - new FileMatcherPattern('/a/[:alnum:]/c'), + yield 'character class [:alnum:]' => [ + new FileMatcherPattern('/a/[[:alnum:]]/c'), [ '/a/1/c' => true, '/a/2/c' => true, '/b/!/c' => false, ], - 'Named character classes', ]; - // TODO: all of these? + 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 @@ -527,7 +557,7 @@ public static function provideCharacterGroup(): Generator 'Collating symbols', ]; - // TODO: all of these? + // 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 @@ -560,13 +590,6 @@ public static function provideRelativePathSegments(): Generator ]; } - public function testExceptionIfPathIsNotAbsolute(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Path "foo/bar" must be absolute'); - FileMatcher::match('foo/bar', new FileMatcherPattern('')); - } - /** * @param array $matchMap */ @@ -585,6 +608,13 @@ public function testMatch(FileMatcherPattern $pattern, array $matchMap, ?string self::assertMap($pattern, $matchMap); } + public function testExceptionIfPathIsNotAbsolute(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path "foo/bar" must be absolute'); + FileMatcher::match('foo/bar', new FileMatcherPattern('')); + } + /** * @param array $matchMap */ From a1b7467708258a25ec3d50e4e5aefbe0ea7d5295 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 22:47:06 +0000 Subject: [PATCH 22/32] Add doc --- src/Util/FileMatcher.php | 47 +++++++++-------------------- src/Util/FileMatcherRegex.php | 39 ++++++++++++++++++++++++ tests/unit/Util/FileMatcherTest.php | 4 +-- 3 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 src/Util/FileMatcherRegex.php diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index e596a0de216..92341e3bf8d 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -13,14 +13,18 @@ use function array_pop; use function count; use function ctype_alpha; -use function preg_match; use function preg_quote; use function sprintf; use function strlen; -use function substr; use RuntimeException; /** + * FileMatcher attempts to emulate the behavior of 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 + * * @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 @@ -43,27 +47,13 @@ private const T_COLON = 'colon'; private const T_CHAR_CLASS = 'char_class'; - public static function match(string $path, FileMatcherPattern $pattern): bool - { - self::assertIsAbsolute($path); - - $regex = self::toRegEx($pattern->path); - - return preg_match($regex, $path) !== 0; - } - /** - * Based on webmozart/glob. - * - * @return string the regular expression for matching the glob + * Compile a regex for the given glob. */ - public static function toRegEx($glob, $flags = 0): string + public static function toRegEx(string $glob): FileMatcherRegex { - self::assertIsAbsolute($glob); - $tokens = self::tokenize($glob); - - $regex = ''; + $regex = ''; foreach ($tokens as $token) { $type = $token[0]; @@ -84,24 +74,15 @@ public static function toRegEx($glob, $flags = 0): string self::T_BRACKET_CLOSE => ']', self::T_HYPHEN => '-', self::T_CHAR_CLASS => '[:' . $token[1] . ':]', - default => '', + default => throw new RuntimeException(sprintf( + 'Unhandled token type: %s - this should not happen', + $type, + )), }; } $regex .= '(/|$)'; - dump($tokens); - dump($regex); - return '{^' . $regex . '}'; - } - - private static function assertIsAbsolute(string $path): void - { - if (substr($path, 0, 1) !== '/') { - throw new RuntimeException(sprintf( - 'Path "%s" must be absolute', - $path, - )); - } + return new FileMatcherRegex('{^' . $regex . '}'); } /** 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/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index a5e1fdd0567..d5298f31f75 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -612,7 +612,7 @@ public function testExceptionIfPathIsNotAbsolute(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Path "foo/bar" must be absolute'); - FileMatcher::match('foo/bar', new FileMatcherPattern('')); + FileMatcher::toRegEx('/a')->matches('foo/bar'); } /** @@ -621,7 +621,7 @@ public function testExceptionIfPathIsNotAbsolute(): void private static function assertMap(FileMatcherPattern $pattern, array $matchMap): void { foreach ($matchMap as $candidate => $shouldMatch) { - $matches = FileMatcher::match($candidate, $pattern); + $matches = FileMatcher::toRegEx($pattern->path)->matches($candidate); if ($matches === $shouldMatch) { self::assertTrue(true); From 6a3eaea524f2dfa6bdd6d1effb2f19df0a46442c Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 22:59:48 +0000 Subject: [PATCH 23/32] Add explanation --- src/Util/FileMatcher.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 92341e3bf8d..3ccfffb455e 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -25,6 +25,12 @@ * - 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 @@ -53,7 +59,17 @@ public static function toRegEx(string $glob): FileMatcherRegex { $tokens = self::tokenize($glob); - $regex = ''; + $tokens = self::processTokens($tokens); + + return self::mapToRegex($tokens); + } + + /** + * @param list $tokens + */ + public static function mapToRegex(array $tokens): FileMatcherRegex + { + $regex = ''; foreach ($tokens as $token) { $type = $token[0]; @@ -111,7 +127,7 @@ private static function tokenize(string $glob): array }; } - return self::processTokens($tokens); + return $tokens; } /** From 3b0c66631c27eedb80614a8632a0402d2d6d51a2 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 23:10:13 +0000 Subject: [PATCH 24/32] Add more comments --- src/Util/FileMatcher.php | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 3ccfffb455e..0c16c0efe89 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -147,26 +147,29 @@ private static function processTokens(array $tokens): array $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; } - // normal globstar + // globstar must be preceded by and succeeded by a directory separator if ( $type === self::T_SLASH && - ($tokens[$offset + 1][0] ?? null) === self::T_ASTERIX && ($tokens[$offset + 2][0] ?? null) === self::T_ASTERIX && ($tokens[$offset + 3][0] ?? null) === 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 `*` in addition to the slash + // we eat the two `*` and the trailing slash $offset += 3; continue; @@ -208,6 +211,10 @@ private static function processTokens(array $tokens): array 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, '[']; @@ -218,6 +225,8 @@ private static function processTokens(array $tokens): array 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 = ''; @@ -240,14 +249,17 @@ private static function processTokens(array $tokens): array $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) { - // if bracket is already open, interpret everything as a - // literal char $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]; @@ -256,7 +268,15 @@ private static function processTokens(array $tokens): array continue; } - if ($type === self::T_BRACKET_CLOSE) { + // 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]; From 69209c28119d67b05923b3dea1ca636c60ced726 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Wed, 12 Mar 2025 23:16:45 +0000 Subject: [PATCH 25/32] Add comment and additional test case --- src/Util/FileMatcher.php | 9 +++++---- tests/unit/Util/FileMatcherTest.php | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 0c16c0efe89..efde12dc2d7 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -19,8 +19,9 @@ use RuntimeException; /** - * FileMatcher attempts to emulate the behavior of PHP's glob function on file - * paths based on POSIX.2. + * 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 @@ -189,6 +190,8 @@ private static function processTokens(array $tokens): array 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]; @@ -249,7 +252,6 @@ private static function processTokens(array $tokens): array $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) { @@ -273,7 +275,6 @@ private static function processTokens(array $tokens): array // // TODO: $bracketOpen === true below is not tested if ($bracketOpen === true && $type === self::T_BRACKET_CLOSE) { - // TODO: this is not tested $bracketOpen = false; diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index d5298f31f75..8cc581d868a 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -143,6 +143,7 @@ public static function provideGlobstar(): Generator yield 'partial leaf globstar' => [ new FileMatcherPattern('/foo/emm**'), [ + '/foo/emm**' => true, '/foo/emmer' => false, '/foo/emm' => false, '/foo/emm/bar' => false, From 213b5aa9f8eaf5e24551ab087bfd5aa08396f510 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Thu, 13 Mar 2025 11:30:55 +0000 Subject: [PATCH 26/32] Inplementing the file matcher --- src/TextUI/Configuration/SourceFilter.php | 34 +++++++++++++++++------ src/Util/FileMatcher.php | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index 845a9b3763f..6edfd1ff863 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,6 +9,10 @@ */ namespace PHPUnit\TextUI\Configuration; +use PHPUnit\Util\FileMatcher; +use PHPUnit\Util\FileMatcherRegex; + + /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * @@ -18,19 +22,23 @@ final class SourceFilter { private static ?self $instance = null; + private Source $source; + /** - * @var array + * @var list */ - private readonly array $map; + private array $includeDirectoryRegexes; + + /** + * @var list + */ + 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(); + return new self($source); } return self::$instance; @@ -39,13 +47,21 @@ public static function instance(): self /** * @param array $map */ - public function __construct(array $map) + public function __construct(Source $source) { - $this->map = $map; + $this->source = $source; + $this->includeDirectoryRegexes = array_map(function (FilterDirectory $directory) { + return FileMatcher::toRegEx($directory->path()); + }, $source->includeDirectories()->asArray()); + $this->excludeDirectoryRegexes = array_map(function (FilterDirectory $directory) { + return FileMatcher::toRegEx($directory->path()); + }, $source->excludeDirectories()->asArray()); } public function includes(string $path): bool { + foreach ($this->source->includeDirectories() as $directory) { + } return isset($this->map[$path]); } } diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index efde12dc2d7..9edbdfd4203 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -68,7 +68,7 @@ public static function toRegEx(string $glob): FileMatcherRegex /** * @param list $tokens */ - public static function mapToRegex(array $tokens): FileMatcherRegex + private static function mapToRegex(array $tokens): FileMatcherRegex { $regex = ''; From 2b7bd2414584fe4ab72b1ab45dd50a038bd288a5 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Thu, 13 Mar 2025 11:41:29 +0000 Subject: [PATCH 27/32] Initial implementation in SourceFilter --- src/TextUI/Configuration/SourceFilter.php | 45 ++++++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index 6edfd1ff863..e2e96f0ee6a 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,11 +9,13 @@ */ namespace PHPUnit\TextUI\Configuration; +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 @@ -21,7 +23,6 @@ final class SourceFilter { private static ?self $instance = null; - private Source $source; /** @@ -38,30 +39,54 @@ public static function instance(): self { if (self::$instance === null) { $source = Registry::get()->source(); + return new self($source); } return self::$instance; } - /** - * @param array $map - */ public function __construct(Source $source) { - $this->source = $source; - $this->includeDirectoryRegexes = array_map(function (FilterDirectory $directory) { + $this->source = $source; + $this->includeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { return FileMatcher::toRegEx($directory->path()); }, $source->includeDirectories()->asArray()); - $this->excludeDirectoryRegexes = array_map(function (FilterDirectory $directory) { + $this->excludeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { return FileMatcher::toRegEx($directory->path()); }, $source->excludeDirectories()->asArray()); } public function includes(string $path): bool { - foreach ($this->source->includeDirectories() as $directory) { + $included = false; + + foreach ($this->source->includeFiles() as $file) { + if ($file->path() === $path) { + $included = true; + } } - return isset($this->map[$path]); + + 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; } } From 87de3700e9b87fd12a0ac3100f99ef8f2992accb Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Thu, 13 Mar 2025 11:55:29 +0000 Subject: [PATCH 28/32] Fix missing types --- src/TextUI/Configuration/SourceFilter.php | 5 +++-- src/Util/FileMatcher.php | 8 ++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index e2e96f0ee6a..c8511db3e03 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -38,9 +38,10 @@ final class SourceFilter public static function instance(): self { if (self::$instance === null) { - $source = Registry::get()->source(); + $source = Registry::get()->source(); + self::$instance = new self($source); - return new self($source); + return self::$instance; } return self::$instance; diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index 9edbdfd4203..fd41ae7f202 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -14,9 +14,7 @@ use function count; use function ctype_alpha; use function preg_quote; -use function sprintf; use function strlen; -use RuntimeException; /** * FileMatcher ultimately attempts to emulate the behavior `php-file-iterator` @@ -90,11 +88,9 @@ private static function mapToRegex(array $tokens): FileMatcherRegex self::T_BRACKET_OPEN => '[', self::T_BRACKET_CLOSE => ']', self::T_HYPHEN => '-', + self::T_COLON => ':', + self::T_BACKSLASH => '\\', self::T_CHAR_CLASS => '[:' . $token[1] . ':]', - default => throw new RuntimeException(sprintf( - 'Unhandled token type: %s - this should not happen', - $type, - )), }; } $regex .= '(/|$)'; From e07a96f734dccbc1f8150485c671a7872bac3c1b Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Sat, 15 Mar 2025 19:55:17 +0000 Subject: [PATCH 29/32] Failing test after rebase --- tests/unit/TextUI/SourceFilterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), ); } From ad68e685226c172ba3eb97ea02b59299aaa62507 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Tue, 18 Mar 2025 09:42:35 +0000 Subject: [PATCH 30/32] Add missing types --- src/Util/FileMatcher.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index fd41ae7f202..b3f741183e4 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -38,19 +38,19 @@ */ final readonly class FileMatcher { - private const T_BRACKET_OPEN = 'bracket_open'; - private const T_BRACKET_CLOSE = 'bracket_close'; - private const T_BANG = 'bang'; - private const T_HYPHEN = 'hyphen'; - private const T_ASTERIX = 'asterix'; - private const T_SLASH = 'slash'; - private const T_BACKSLASH = 'backslash'; - private const T_CHAR = 'char'; - private const T_GREEDY_GLOBSTAR = 'greedy_globstar'; - private const T_QUERY = 'query'; - private const T_GLOBSTAR = 'globstar'; - private const T_COLON = 'colon'; - private const T_CHAR_CLASS = 'char_class'; + 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. From ad0b105d089f1e3674840679361f1d05b2cfa928 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Tue, 18 Mar 2025 09:45:31 +0000 Subject: [PATCH 31/32] Use the filematcherpattern --- src/Util/FileMatcher.php | 4 ++-- tests/unit/Util/FileMatcherTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Util/FileMatcher.php b/src/Util/FileMatcher.php index b3f741183e4..b6445215cc5 100644 --- a/src/Util/FileMatcher.php +++ b/src/Util/FileMatcher.php @@ -55,9 +55,9 @@ /** * Compile a regex for the given glob. */ - public static function toRegEx(string $glob): FileMatcherRegex + public static function toRegEx(FileMatcherPattern $pattern): FileMatcherRegex { - $tokens = self::tokenize($glob); + $tokens = self::tokenize($pattern->path); $tokens = self::processTokens($tokens); return self::mapToRegex($tokens); diff --git a/tests/unit/Util/FileMatcherTest.php b/tests/unit/Util/FileMatcherTest.php index 8cc581d868a..18481b40a3f 100644 --- a/tests/unit/Util/FileMatcherTest.php +++ b/tests/unit/Util/FileMatcherTest.php @@ -613,7 +613,7 @@ public function testExceptionIfPathIsNotAbsolute(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Path "foo/bar" must be absolute'); - FileMatcher::toRegEx('/a')->matches('foo/bar'); + FileMatcher::toRegEx(new FileMatcherPattern('/a'))->matches('foo/bar'); } /** @@ -622,7 +622,7 @@ public function testExceptionIfPathIsNotAbsolute(): void private static function assertMap(FileMatcherPattern $pattern, array $matchMap): void { foreach ($matchMap as $candidate => $shouldMatch) { - $matches = FileMatcher::toRegEx($pattern->path)->matches($candidate); + $matches = FileMatcher::toRegEx($pattern)->matches($candidate); if ($matches === $shouldMatch) { self::assertTrue(true); From 39baf161f4657c1eb343fd6d70d86bb10896f9e4 Mon Sep 17 00:00:00 2001 From: Daniel Leech Date: Tue, 18 Mar 2025 09:47:43 +0000 Subject: [PATCH 32/32] Apply PHPStan fixes --- src/TextUI/Configuration/SourceFilter.php | 5 +++-- src/Util/FileMatcherPattern.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index c8511db3e03..949a7b6ad4d 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\TextUI\Configuration; +use PHPUnit\Util\FileMatcherPattern; use function array_map; use PHPUnit\Util\FileMatcher; use PHPUnit\Util\FileMatcherRegex; @@ -52,11 +53,11 @@ public function __construct(Source $source) $this->source = $source; $this->includeDirectoryRegexes = array_map(static function (FilterDirectory $directory) { - return FileMatcher::toRegEx($directory->path()); + return FileMatcher::toRegEx(new FileMatcherPattern($directory->path())); }, $source->includeDirectories()->asArray()); $this->excludeDirectoryRegexes = array_map(static function (FilterDirectory $directory) { - return FileMatcher::toRegEx($directory->path()); + return FileMatcher::toRegEx(new FileMatcherPattern($directory->path())); }, $source->excludeDirectories()->asArray()); } diff --git a/src/Util/FileMatcherPattern.php b/src/Util/FileMatcherPattern.php index 9ecd4ef16d4..66bb15c7d05 100644 --- a/src/Util/FileMatcherPattern.php +++ b/src/Util/FileMatcherPattern.php @@ -9,7 +9,7 @@ */ namespace PHPUnit\Util; -class FileMatcherPattern +final class FileMatcherPattern { public function __construct(public string $path) {