From 53ecc2cd0556bc16ae1d9cfa9143abeeca0746a5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 3 Oct 2026 09:36:17 +0700 Subject: [PATCH 1/2] Improve skip path matching performance by dropping redundant descendant glob checks --- src/File/SkipPathMatcher.php | 29 +++++++++++++++++++---------- tests/File/SkipPathMatcherTest.php | 10 ++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/File/SkipPathMatcher.php b/src/File/SkipPathMatcher.php index ce122bd1..8b71bb01 100644 --- a/src/File/SkipPathMatcher.php +++ b/src/File/SkipPathMatcher.php @@ -7,11 +7,11 @@ use Boundwize\StructArmed\Util\Path; use function array_unique; -use function array_values; use function fnmatch; use function implode; use function realpath; use function sort; +use function str_ends_with; use function str_starts_with; use function strlen; use function strpbrk; @@ -49,7 +49,12 @@ final class SkipPathMatcher */ private readonly array $pathPrefixesWithSlash; - /** @var list */ + /** + * Glob patterns, each followed by its "/*" descendant pattern unless the + * pattern already ends with "*", which then matches "/..." too. + * + * @var list + */ private readonly array $patterns; private readonly bool $hasMatchers; @@ -106,8 +111,18 @@ private function __construct(string $basePath, array $skipPaths) $pathPrefixesWithSlash[] = $pathPrefix . '/'; } + $patternsWithDescendants = []; + foreach (array_unique($patterns) as $pattern) { + $patternsWithDescendants[] = $pattern; + + // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. + if (! str_ends_with($pattern, '*')) { + $patternsWithDescendants[] = $pattern . '/*'; + } + } + $this->pathPrefixesWithSlash = $pathPrefixesWithSlash; - $this->patterns = array_values(array_unique($patterns)); + $this->patterns = $patternsWithDescendants; $this->hasMatchers = $pathPrefixesWithSlash !== [] || $this->patterns !== []; } @@ -140,17 +155,11 @@ private function computeIsSkipped(string $path): bool : $normalisedPath; foreach ($this->patterns as $pattern) { - if ($this->matchesPattern($pattern, $normalisedPath) || $this->matchesPattern($pattern, $relativePath)) { + if (fnmatch($pattern, $normalisedPath) || fnmatch($pattern, $relativePath)) { return true; } } return false; } - - private function matchesPattern(string $pattern, string $path): bool - { - // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. - return fnmatch($pattern, $path) || fnmatch($pattern . '/*', $path); - } } diff --git a/tests/File/SkipPathMatcherTest.php b/tests/File/SkipPathMatcherTest.php index bb4d18e1..3a850beb 100644 --- a/tests/File/SkipPathMatcherTest.php +++ b/tests/File/SkipPathMatcherTest.php @@ -72,6 +72,16 @@ public function testDirectoryGlobSkipsDescendantsWithoutMatchingSiblingPrefixes( } } + public function testTrailingWildcardGlobSkipsDescendants(): void + { + $skipPathMatcher = SkipPathMatcher::compile('/project', ['*tests*', 'build*']); + + $this->assertTrue($skipPathMatcher->isSkipped('/project/tests/Foo.php')); + $this->assertTrue($skipPathMatcher->isSkipped('/project/src/unittests/Nested/Foo.php')); + $this->assertTrue($skipPathMatcher->isSkipped('/project/build-cache/Nested/Foo.php')); + $this->assertFalse($skipPathMatcher->isSkipped('/project/src/Foo.php')); + } + public function testLeadingSlashSkipPathMatchesOnlyTheAbsoluteLocation(): void { $skipPathMatcher = SkipPathMatcher::compile('/project', ['/vendor']); From 8c014ddeaaefd16187f7129b91963fb76f02c4dc Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 3 Oct 2026 09:43:26 +0700 Subject: [PATCH 2/2] single call --- src/File/SkipPathMatcher.php | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/File/SkipPathMatcher.php b/src/File/SkipPathMatcher.php index 8b71bb01..1b893009 100644 --- a/src/File/SkipPathMatcher.php +++ b/src/File/SkipPathMatcher.php @@ -7,11 +7,11 @@ use Boundwize\StructArmed\Util\Path; use function array_unique; +use function array_values; use function fnmatch; use function implode; use function realpath; use function sort; -use function str_ends_with; use function str_starts_with; use function strlen; use function strpbrk; @@ -50,8 +50,8 @@ final class SkipPathMatcher private readonly array $pathPrefixesWithSlash; /** - * Glob patterns, each followed by its "/*" descendant pattern unless the - * pattern already ends with "*", which then matches "/..." too. + * Glob patterns with "/*" appended, matched against "$path/" so a single + * fnmatch() covers both the exact path and its descendants. * * @var list */ @@ -90,7 +90,8 @@ private function __construct(string $basePath, array $skipPaths) $normalisedSkipPath = Path::normalise($skipPath); if (strpbrk($skipPath, '*?[') !== false) { - $patterns[] = $normalisedSkipPath; + // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. + $patterns[] = $normalisedSkipPath . '/*'; continue; } @@ -111,18 +112,8 @@ private function __construct(string $basePath, array $skipPaths) $pathPrefixesWithSlash[] = $pathPrefix . '/'; } - $patternsWithDescendants = []; - foreach (array_unique($patterns) as $pattern) { - $patternsWithDescendants[] = $pattern; - - // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. - if (! str_ends_with($pattern, '*')) { - $patternsWithDescendants[] = $pattern . '/*'; - } - } - $this->pathPrefixesWithSlash = $pathPrefixesWithSlash; - $this->patterns = $patternsWithDescendants; + $this->patterns = array_values(array_unique($patterns)); $this->hasMatchers = $pathPrefixesWithSlash !== [] || $this->patterns !== []; } @@ -150,12 +141,12 @@ private function computeIsSkipped(string $path): bool return false; } - $relativePath = str_starts_with($normalisedPath, $this->normalisedBasePathWithSlash) - ? substr($normalisedPath, strlen($this->normalisedBasePathWithSlash)) - : $normalisedPath; + $relativePathWithSlash = str_starts_with($pathWithSlash, $this->normalisedBasePathWithSlash) + ? substr($pathWithSlash, strlen($this->normalisedBasePathWithSlash)) + : $pathWithSlash; foreach ($this->patterns as $pattern) { - if (fnmatch($pattern, $normalisedPath) || fnmatch($pattern, $relativePath)) { + if (fnmatch($pattern, $pathWithSlash) || fnmatch($pattern, $relativePathWithSlash)) { return true; } }