From 88af03469ff609b2ef26f26a09c70130e4b8dc19 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 16:01:01 +0530 Subject: [PATCH 1/8] feat: add Glob validator with gitignore-style pattern matching --- src/Validator/Glob.php | 200 +++++++++++++++ tests/Validator/GlobTest.php | 461 +++++++++++++++++++++++++++++++++++ 2 files changed, 661 insertions(+) create mode 100644 src/Validator/Glob.php create mode 100644 tests/Validator/GlobTest.php diff --git a/src/Validator/Glob.php b/src/Validator/Glob.php new file mode 100644 index 0000000..b7509c0 --- /dev/null +++ b/src/Validator/Glob.php @@ -0,0 +1,200 @@ +patterns)) { + return true; + } + + $include = array_filter($this->patterns, fn ($p) => !str_starts_with($p, '!')); + $exclude = array_filter($this->patterns, fn ($p) => str_starts_with($p, '!')); + + if (empty($include)) { + foreach ($exclude as $pattern) { + if ($this->match($value, substr($pattern, 1))) { + return false; + } + } + + return true; + } + + $isSpecific = fn ($pattern) => !str_contains($pattern, '*') && !str_contains($pattern, '?'); + + foreach ($include as $pattern) { + if ($isSpecific($pattern) && $this->match($value, $pattern)) { + return true; + } + } + + foreach ($exclude as $pattern) { + if ($this->match($value, substr($pattern, 1))) { + return false; + } + } + + foreach ($include as $pattern) { + if (!$isSpecific($pattern) && $this->match($value, $pattern)) { + return true; + } + } + + return false; + } + + /** + * Is array + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } + + /** + * Match a subject against a single pattern. + * Uses fnmatch() for patterns without **, regex for globstar patterns. + */ + private function match(string $subject, string $pattern): bool + { + if (!str_contains($pattern, '**')) { + return fnmatch($pattern, $subject, FNM_PATHNAME); + } + + return $this->matchGlobstar($subject, $pattern); + } + + /** + * Match using a regex built from a pattern that contains **. + * Handles **, *, ?, [abc] character classes, and \ escape sequences. + */ + private function matchGlobstar(string $subject, string $pattern): bool + { + $regex = ''; + $len = strlen($pattern); + $i = 0; + + while ($i < $len) { + $char = $pattern[$i]; + + if ($char === '\\' && $i + 1 < $len) { + $regex .= preg_quote($pattern[$i + 1], '~'); + $i += 2; + } elseif ($char === '[') { + $j = $i + 1; + $bracketContent = ''; + + // Allow ] as first char inside bracket (or after !) + if ($j < $len && ($pattern[$j] === '!' || $pattern[$j] === '^')) { + $bracketContent .= $pattern[$j]; + $j++; + } + if ($j < $len && $pattern[$j] === ']') { + $bracketContent .= ']'; + $j++; + } + + while ($j < $len && $pattern[$j] !== ']') { + $bracketContent .= $pattern[$j]; + $j++; + } + + if ($j < $len) { + // Well-formed [...] — normalise ! negation to ^ + $inner = $bracketContent; + if (str_starts_with($inner, '!')) { + $inner = '^' . substr($inner, 1); + } + $regex .= '[' . $inner . ']'; + $i = $j + 1; + } else { + // Unclosed bracket — treat [ as a literal character + $regex .= preg_quote('[', '~'); + $i++; + } + } elseif ($char === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') { + $prevSlash = $i === 0 || $pattern[$i - 1] === '/'; + $nextSlash = isset($pattern[$i + 2]) && $pattern[$i + 2] === '/'; + + if ($prevSlash && $nextSlash) { + // a/**/b — zero or more intermediate directories + $regex .= '(?:.+/)?'; + $i += 3; + } else { + // foo/** or standalone ** — matches everything + $regex .= '.*'; + $i += 2; + } + } elseif ($char === '*') { + $regex .= '[^/]*'; + $i++; + } elseif ($char === '?') { + $regex .= '[^/]'; + $i++; + } else { + $regex .= preg_quote($char, '~'); + $i++; + } + } + + return (bool) preg_match('~^' . $regex . '$~', $subject); + } +} diff --git a/tests/Validator/GlobTest.php b/tests/Validator/GlobTest.php new file mode 100644 index 0000000..a84b2b7 --- /dev/null +++ b/tests/Validator/GlobTest.php @@ -0,0 +1,461 @@ +assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('feature/anything')); + $this->assertTrue($validator->isValid('src/deep/nested/file.php')); + } + + // ------------------------------------------------------------------------- + // Pure inclusion — OR semantics (any one match is enough) + // ------------------------------------------------------------------------- + + public function testSingleExactInclusion(): void + { + $validator = new Glob(['main']); + $this->assertTrue($validator->isValid('main')); + $this->assertFalse($validator->isValid('develop')); + $this->assertFalse($validator->isValid('main-extra')); + } + + public function testMultipleExactInclusionsOr(): void + { + $validator = new Glob(['main', 'develop', 'staging']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); + $this->assertTrue($validator->isValid('staging')); + $this->assertFalse($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('production')); + } + + public function testSingleWildcardInclusion(): void + { + $validator = new Glob(['feature/*']); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertTrue($validator->isValid('feature/bar')); + $this->assertFalse($validator->isValid('feature/foo/bar')); // * does not cross / + $this->assertFalse($validator->isValid('main')); + } + + public function testWildcardWithDash(): void + { + $validator = new Glob(['feature/test-*']); + $this->assertTrue($validator->isValid('feature/test-1')); + $this->assertTrue($validator->isValid('feature/test-abc')); + $this->assertFalse($validator->isValid('feature/other')); + $this->assertFalse($validator->isValid('feature/test')); + } + + public function testQuestionMarkWildcard(): void + { + $validator = new Glob(['v?.?']); + $this->assertTrue($validator->isValid('v1.0')); + $this->assertTrue($validator->isValid('v2.5')); + $this->assertFalse($validator->isValid('v10.0')); // ? matches exactly one char, not two + $this->assertFalse($validator->isValid('v1/0')); // ? does not cross / + } + + public function testQuestionMarkDoesNotCrossSlash(): void + { + $validator = new Glob(['feature/?']); + $this->assertTrue($validator->isValid('feature/a')); + $this->assertTrue($validator->isValid('feature/z')); + $this->assertFalse($validator->isValid('feature/ab')); // ? matches only one char + $this->assertFalse($validator->isValid('feature/a/b')); // ? does not cross / + $this->assertFalse($validator->isValid('feature/')); + } + + public function testQuestionMarkMixedWithStar(): void + { + $validator = new Glob(['fix-?.*']); + $this->assertTrue($validator->isValid('fix-1.php')); + $this->assertTrue($validator->isValid('fix-a.js')); + $this->assertFalse($validator->isValid('fix-12.php')); // ? matches only one char + $this->assertFalse($validator->isValid('fix-.php')); // ? requires exactly one char + } + + public function testDoubleWildcardAtEnd(): void + { + $validator = new Glob(['src/**']); + $this->assertTrue($validator->isValid('src/foo.js')); + $this->assertTrue($validator->isValid('src/a/b/c.js')); + $this->assertTrue($validator->isValid('src/deep/nested/file.php')); + $this->assertFalse($validator->isValid('lib/foo.js')); + } + + public function testDoubleWildcardInMiddle(): void + { + $validator = new Glob(['a/**/b']); + $this->assertTrue($validator->isValid('a/b')); // zero intermediate dirs + $this->assertTrue($validator->isValid('a/x/b')); // one + $this->assertTrue($validator->isValid('a/x/y/b')); // two + $this->assertFalse($validator->isValid('a/b/c')); + $this->assertFalse($validator->isValid('x/a/b')); + } + + public function testDoubleWildcardAtStart(): void + { + $validator = new Glob(['**/foo']); + $this->assertTrue($validator->isValid('foo')); // zero leading dirs + $this->assertTrue($validator->isValid('a/foo')); // one + $this->assertTrue($validator->isValid('a/b/foo')); // two + $this->assertFalse($validator->isValid('foobar')); + $this->assertFalse($validator->isValid('a/foobar')); + } + + public function testMixedExactAndWildcardInclusions(): void + { + $validator = new Glob(['main', 'feature/*']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('develop')); + $this->assertFalse($validator->isValid('feature/foo/bar')); + } + + // ------------------------------------------------------------------------- + // Pure exclusion — AND semantics (must not match any exclusion) + // ------------------------------------------------------------------------- + + public function testSingleExactExclusion(): void + { + $validator = new Glob(['!main']); + $this->assertFalse($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); + $this->assertTrue($validator->isValid('feature/foo')); + } + + public function testMultipleExactExclusionsAnd(): void + { + $validator = new Glob(['!main', '!develop']); + $this->assertFalse($validator->isValid('main')); + $this->assertFalse($validator->isValid('develop')); + $this->assertTrue($validator->isValid('staging')); + $this->assertTrue($validator->isValid('feature/foo')); + } + + public function testWildcardExclusion(): void + { + $validator = new Glob(['!feature/*']); + $this->assertFalse($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('feature/bar')); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('hotfix/urgent')); + } + + public function testDoubleWildcardExclusion(): void + { + $validator = new Glob(['!src/**']); + $this->assertFalse($validator->isValid('src/foo.js')); + $this->assertFalse($validator->isValid('src/a/b/c.js')); + $this->assertTrue($validator->isValid('lib/foo.js')); + $this->assertTrue($validator->isValid('main')); + } + + // ------------------------------------------------------------------------- + // Mixed inclusion + exclusion + // ------------------------------------------------------------------------- + + public function testInclusionTakesPrecedenceWhenBothMatch(): void + { + $validator = new Glob(['!feature/*', 'feature/abc']); + $this->assertTrue($validator->isValid('feature/abc')); // inclusion wins + $this->assertFalse($validator->isValid('feature/xyz')); // only exclusion matches + $this->assertFalse($validator->isValid('main')); // no inclusion matches + } + + public function testInclusionWithNoMatchFails(): void + { + $validator = new Glob(['main', '!develop']); + $this->assertTrue($validator->isValid('main')); + $this->assertFalse($validator->isValid('develop')); // excluded even if inclusion didn't match + $this->assertFalse($validator->isValid('staging')); // no inclusion match + } + + public function testExclusionBlocksWhenInclusionDoesNotMatch(): void + { + $validator = new Glob(['feature/*', '!hotfix/*']); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('hotfix/urgent')); // no inclusion match, also excluded + $this->assertFalse($validator->isValid('main')); // no inclusion match + } + + public function testMultipleInclusionsWithSingleExclusion(): void + { + $validator = new Glob(['main', 'develop', 'feature/*', '!feature/wip']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('feature/wip')); // specific exclusion overrides wildcard inclusion + $this->assertFalse($validator->isValid('hotfix/urgent')); // no inclusion match + } + + public function testSingleInclusionWithMultipleExclusions(): void + { + $validator = new Glob(['feature/**', '!feature/wip', '!feature/experimental']); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertTrue($validator->isValid('feature/a/b')); + $this->assertFalse($validator->isValid('feature/wip')); + $this->assertFalse($validator->isValid('feature/experimental')); + $this->assertFalse($validator->isValid('main')); + } + + public function testMultipleInclusionsWithMultipleExclusions(): void + { + $validator = new Glob(['main', 'feature/**', '!feature/wip', '!feature/experimental']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertTrue($validator->isValid('feature/a/b')); + $this->assertFalse($validator->isValid('feature/wip')); + $this->assertFalse($validator->isValid('feature/experimental')); + $this->assertFalse($validator->isValid('develop')); + } + + public function testWildcardExclusionOverridesWildcardInclusion(): void + { + $validator = new Glob(['src/**', '!src/generated/**']); + $this->assertTrue($validator->isValid('src/components/Button.php')); + $this->assertTrue($validator->isValid('src/utils/helper.js')); + $this->assertFalse($validator->isValid('src/generated/Foo.php')); + $this->assertFalse($validator->isValid('src/generated/bar/Baz.php')); + $this->assertFalse($validator->isValid('lib/other.php')); + } + + public function testSpecificInclusionOverridesWildcardExclusion(): void + { + $validator = new Glob(['feature/hotfix/critical', '!feature/**']); + $this->assertTrue($validator->isValid('feature/hotfix/critical')); // inclusion wins + $this->assertFalse($validator->isValid('feature/foo')); + $this->assertFalse($validator->isValid('feature/hotfix/other')); + $this->assertFalse($validator->isValid('main')); + } + + public function testOnlyExclusionsDefaultToTrueUnlessExcluded(): void + { + $validator = new Glob(['!main', '!develop']); + $this->assertFalse($validator->isValid('main')); + $this->assertFalse($validator->isValid('develop')); + $this->assertTrue($validator->isValid('staging')); + $this->assertTrue($validator->isValid('feature/foo')); + } + + // ------------------------------------------------------------------------- + // Standalone wildcards + // ------------------------------------------------------------------------- + + public function testStarAloneMatchesSingleSegmentOnly(): void + { + $validator = new Glob(['*']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('develop')); + $this->assertFalse($validator->isValid('feature/foo')); // * cannot cross / + $this->assertFalse($validator->isValid('a/b/c')); + } + + public function testDoubleStarAloneMatchesEverything(): void + { + $validator = new Glob(['**']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('feature/foo')); + $this->assertTrue($validator->isValid('src/a/b/c/d/file.php')); + } + + // ------------------------------------------------------------------------- + // Extension patterns — * scope vs. ** scope + // ------------------------------------------------------------------------- + + public function testStarDotExtMatchesRootLevelOnly(): void + { + $validator = new Glob(['*.php']); + $this->assertTrue($validator->isValid('Foo.php')); + $this->assertTrue($validator->isValid('index.php')); + $this->assertFalse($validator->isValid('src/Foo.php')); // * does not cross / + $this->assertFalse($validator->isValid('a/b/Foo.php')); + $this->assertFalse($validator->isValid('Foo.js')); + } + + public function testDoubleStarSlashExtMatchesAnyDepth(): void + { + $validator = new Glob(['**/*.php']); + $this->assertTrue($validator->isValid('Foo.php')); + $this->assertTrue($validator->isValid('src/Foo.php')); + $this->assertTrue($validator->isValid('src/components/Foo.php')); + $this->assertTrue($validator->isValid('a/b/c/d/Foo.php')); + $this->assertFalse($validator->isValid('Foo.js')); + $this->assertFalse($validator->isValid('src/Foo.js')); + } + + public function testDirPrefixDoubleStarExtPattern(): void + { + $validator = new Glob(['src/**/*.php']); + $this->assertTrue($validator->isValid('src/Foo.php')); + $this->assertTrue($validator->isValid('src/components/Foo.php')); + $this->assertTrue($validator->isValid('src/a/b/c/Foo.php')); + $this->assertFalse($validator->isValid('Foo.php')); + $this->assertFalse($validator->isValid('lib/Foo.php')); + $this->assertFalse($validator->isValid('src/Foo.js')); + } + + // ------------------------------------------------------------------------- + // Dots as literal characters + // ------------------------------------------------------------------------- + + public function testDotsInPatternAreLiteral(): void + { + $validator = new Glob(['release-1.0.0']); + $this->assertTrue($validator->isValid('release-1.0.0')); + $this->assertFalse($validator->isValid('release-1X0Y0')); + $this->assertFalse($validator->isValid('release-1.0.0-hotfix')); + } + + public function testVersionWildcardBranchPattern(): void + { + $validator = new Glob(['v*.*.*']); + $this->assertTrue($validator->isValid('v1.2.3')); + $this->assertTrue($validator->isValid('v10.20.30')); + $this->assertTrue($validator->isValid('v1.2.3.4')); + $this->assertFalse($validator->isValid('v1.2')); + $this->assertFalse($validator->isValid('1.2.3')); + $this->assertFalse($validator->isValid('v1/2/3')); + } + + public function testDottedFilenamePattern(): void + { + $validator = new Glob(['*.test.js']); + $this->assertTrue($validator->isValid('Button.test.js')); + $this->assertTrue($validator->isValid('App.test.js')); + $this->assertFalse($validator->isValid('ButtonXtestYjs')); + $this->assertFalse($validator->isValid('src/Button.test.js')); + $this->assertFalse($validator->isValid('Button.test.ts')); + } + + // ------------------------------------------------------------------------- + // Prefix wildcard + // ------------------------------------------------------------------------- + + public function testPrefixWildcardBranchPattern(): void + { + $validator = new Glob(['main*']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('main-extra')); + $this->assertTrue($validator->isValid('mainline')); + $this->assertFalse($validator->isValid('main/branch')); + $this->assertFalse($validator->isValid('develop')); + } + + // ------------------------------------------------------------------------- + // Deep nesting + // ------------------------------------------------------------------------- + + public function testDoubleWildcardInMiddleDeepNesting(): void + { + $validator = new Glob(['a/**/b']); + $this->assertTrue($validator->isValid('a/x/y/z/b')); + $this->assertTrue($validator->isValid('a/p/q/r/s/b')); + $this->assertTrue($validator->isValid('a/1/2/3/4/5/b')); + $this->assertFalse($validator->isValid('a/x/y/z/b/extra')); + $this->assertFalse($validator->isValid('prefix/a/x/b')); + } + + public function testDoubleWildcardAtStartDeepNesting(): void + { + $validator = new Glob(['**/README.md']); + $this->assertTrue($validator->isValid('README.md')); + $this->assertTrue($validator->isValid('docs/README.md')); + $this->assertTrue($validator->isValid('a/b/c/d/README.md')); + $this->assertTrue($validator->isValid('x/y/z/w/v/README.md')); + $this->assertFalse($validator->isValid('a/b/c/README.md.bak')); + $this->assertFalse($validator->isValid('a/b/c/README.md/extra')); + } + + // ------------------------------------------------------------------------- + // Real-world path patterns + // ------------------------------------------------------------------------- + + public function testGeneratedFilesAnywhereExclusion(): void + { + $validator = new Glob(['!**/generated/**']); + $this->assertFalse($validator->isValid('generated/Foo.php')); + $this->assertFalse($validator->isValid('src/generated/Foo.php')); + $this->assertFalse($validator->isValid('src/api/generated/Bar.php')); + $this->assertFalse($validator->isValid('generated/sub/deep/File.php')); + $this->assertTrue($validator->isValid('src/components/Button.php')); + $this->assertTrue($validator->isValid('main')); + } + + public function testMultipleExtensionInclusions(): void + { + $validator = new Glob(['**/*.php', '**/*.js']); + $this->assertTrue($validator->isValid('index.php')); + $this->assertTrue($validator->isValid('src/App.php')); + $this->assertTrue($validator->isValid('index.js')); + $this->assertTrue($validator->isValid('src/components/App.js')); + $this->assertFalse($validator->isValid('styles.css')); + $this->assertFalse($validator->isValid('src/styles.css')); + $this->assertFalse($validator->isValid('README.md')); + } + + // ------------------------------------------------------------------------- + // Named-prefix single-level branch + // ------------------------------------------------------------------------- + + public function testReleaseBranchPattern(): void + { + $validator = new Glob(['release/*']); + $this->assertTrue($validator->isValid('release/1.0')); + $this->assertTrue($validator->isValid('release/hotfix')); + $this->assertTrue($validator->isValid('release/2024-01-15')); + $this->assertFalse($validator->isValid('release/1.0/patch')); + $this->assertFalse($validator->isValid('release')); + $this->assertFalse($validator->isValid('main')); + } + + // ------------------------------------------------------------------------- + // Case sensitivity + // ------------------------------------------------------------------------- + + public function testPatternMatchingIsCaseSensitive(): void + { + $branchValidator = new Glob(['main']); + $this->assertTrue($branchValidator->isValid('main')); + $this->assertFalse($branchValidator->isValid('Main')); + $this->assertFalse($branchValidator->isValid('MAIN')); + + $wildcardValidator = new Glob(['feature/*']); + $this->assertTrue($wildcardValidator->isValid('feature/foo')); + $this->assertFalse($wildcardValidator->isValid('Feature/foo')); + $this->assertFalse($wildcardValidator->isValid('FEATURE/foo')); + } + + // ------------------------------------------------------------------------- + // Metadata + // ------------------------------------------------------------------------- + + public function testValidatorMetadata(): void + { + $validator = new Glob([]); + $this->assertFalse($validator->isArray()); + $this->assertSame(\Utopia\Validator::TYPE_STRING, $validator->getType()); + $this->assertNotEmpty($validator->getDescription()); + } + + public function testRejectsNonStringValues(): void + { + $validator = new Glob(['main']); + $this->assertFalse($validator->isValid(123)); + $this->assertFalse($validator->isValid(null)); + $this->assertFalse($validator->isValid(['main'])); + $this->assertFalse($validator->isValid(true)); + } +} From 588411e81170b370febd1ea68e7e4da1b963636f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 16:09:11 +0530 Subject: [PATCH 2/8] fix: treat [...] character-class patterns as wildcards in isSpecific check --- src/Validator/Glob.php | 2 +- tests/Validator/GlobTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Validator/Glob.php b/src/Validator/Glob.php index b7509c0..72cb755 100644 --- a/src/Validator/Glob.php +++ b/src/Validator/Glob.php @@ -66,7 +66,7 @@ public function isValid($value): bool return true; } - $isSpecific = fn ($pattern) => !str_contains($pattern, '*') && !str_contains($pattern, '?'); + $isSpecific = fn ($pattern) => !str_contains($pattern, '*') && !str_contains($pattern, '?') && !str_contains($pattern, '['); foreach ($include as $pattern) { if ($isSpecific($pattern) && $this->match($value, $pattern)) { diff --git a/tests/Validator/GlobTest.php b/tests/Validator/GlobTest.php index a84b2b7..566974e 100644 --- a/tests/Validator/GlobTest.php +++ b/tests/Validator/GlobTest.php @@ -438,6 +438,37 @@ public function testPatternMatchingIsCaseSensitive(): void $this->assertFalse($wildcardValidator->isValid('FEATURE/foo')); } + // ------------------------------------------------------------------------- + // Character class patterns + // ------------------------------------------------------------------------- + + public function testCharacterClassInclusion(): void + { + $validator = new Glob(['[Mm]ain']); + $this->assertTrue($validator->isValid('main')); + $this->assertTrue($validator->isValid('Main')); + $this->assertFalse($validator->isValid('MAIN')); + $this->assertFalse($validator->isValid('develop')); + } + + public function testCharacterClassInclusionWithWildcardExclusion(): void + { + // [Mm]ain is a character-class pattern (not a literal), so it must not + // short-circuit before exclusions are evaluated. + $validator = new Glob(['[Mm]ain', '!**']); + $this->assertFalse($validator->isValid('main')); + $this->assertFalse($validator->isValid('Main')); + } + + public function testCharacterClassExclusion(): void + { + $validator = new Glob(['!feature/[0-9]*']); + $this->assertFalse($validator->isValid('feature/123')); + $this->assertFalse($validator->isValid('feature/9fix')); + $this->assertTrue($validator->isValid('feature/abc')); + $this->assertTrue($validator->isValid('main')); + } + // ------------------------------------------------------------------------- // Metadata // ------------------------------------------------------------------------- From 780e75f389de392e3410b673058283c73f340df6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 16:58:36 +0530 Subject: [PATCH 3/8] test: add gregpriday/gitignore-php pattern parity tests; fix [ routing in match() --- src/Validator/Glob.php | 6 +- tests/Validator/GlobTest.php | 117 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/Validator/Glob.php b/src/Validator/Glob.php index 72cb755..9cd6070 100644 --- a/src/Validator/Glob.php +++ b/src/Validator/Glob.php @@ -111,11 +111,13 @@ public function getType(): string /** * Match a subject against a single pattern. - * Uses fnmatch() for patterns without **, regex for globstar patterns. + * Uses fnmatch() for simple patterns without ** or [, regex otherwise. + * Patterns with [ are routed through matchGlobstar to correctly handle + * unclosed brackets (treated as literals) and [!...] negated classes. */ private function match(string $subject, string $pattern): bool { - if (!str_contains($pattern, '**')) { + if (!str_contains($pattern, '**') && !str_contains($pattern, '[')) { return fnmatch($pattern, $subject, FNM_PATHNAME); } diff --git a/tests/Validator/GlobTest.php b/tests/Validator/GlobTest.php index 566974e..65fe3c0 100644 --- a/tests/Validator/GlobTest.php +++ b/tests/Validator/GlobTest.php @@ -489,4 +489,121 @@ public function testRejectsNonStringValues(): void $this->assertFalse($validator->isValid(['main'])); $this->assertFalse($validator->isValid(true)); } + + // ------------------------------------------------------------------------- + // Character class and escaped character coverage (gregpriday/gitignore-php parity) + // ------------------------------------------------------------------------- + + public function testLiteralsExact(): void + { + $this->assertTrue((new Glob(['file.txt']))->isValid('file.txt')); + $this->assertFalse((new Glob(['file.txt']))->isValid('file.txt.bak')); + } + + public function testSingleAsteriskDoesNotCrossSlash(): void + { + $this->assertTrue((new Glob(['*.txt']))->isValid('file.txt')); + $this->assertTrue((new Glob(['*.txt']))->isValid('another.txt')); + $this->assertFalse((new Glob(['*.txt']))->isValid('file.txt.bak')); + $this->assertFalse((new Glob(['*.txt']))->isValid('dir/file.txt')); // * does not cross / + } + + public function testQuestionMarkSingleChar(): void + { + $this->assertTrue((new Glob(['file.?xt']))->isValid('file.txt')); + $this->assertTrue((new Glob(['file.?xt']))->isValid('file.dxt')); + $this->assertFalse((new Glob(['file.?xt']))->isValid('file.xtt')); + } + + public function testDoubleStarPrefixFileMatch(): void + { + $this->assertTrue((new Glob(['**/file.txt']))->isValid('file.txt')); + $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/file.txt')); + $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/subdir/file.txt')); + $this->assertFalse((new Glob(['**/file.txt']))->isValid('file.txt.bak')); + } + + public function testDoubleStarMiddleFileMatch(): void + { + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/file.txt')); + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/file.txt')); + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); + $this->assertFalse((new Glob(['src/**/file.txt']))->isValid('other/file.txt')); + } + + public function testEscapedAsteriskIsLiteral(): void + { + $this->assertTrue((new Glob(['file\*.txt']))->isValid('file*.txt')); + $this->assertFalse((new Glob(['file\*.txt']))->isValid('fileX.txt')); + } + + public function testBasicCharacterClasses(): void + { + $this->assertTrue((new Glob(['[a]bc.txt']))->isValid('abc.txt')); + $this->assertFalse((new Glob(['[a]bc.txt']))->isValid('bbc.txt')); + $this->assertTrue((new Glob(['[a-z]est.txt']))->isValid('test.txt')); + $this->assertFalse((new Glob(['[a-z]est.txt']))->isValid('Test.txt')); + $this->assertTrue((new Glob(['[A-Z]est.txt']))->isValid('Test.txt')); + $this->assertFalse((new Glob(['[A-Z]est.txt']))->isValid('test.txt')); + $this->assertTrue((new Glob(['file[0-9].log']))->isValid('file5.log')); + $this->assertFalse((new Glob(['file[0-9].log']))->isValid('fileA.log')); + $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); + $this->assertFalse((new Glob(['[a-zA-Z]file.txt']))->isValid('1file.txt')); + } + + public function testNegatedCharacterClasses(): void + { + $this->assertTrue((new Glob(['[!a-z]file.txt']))->isValid('Afile.txt')); + $this->assertFalse((new Glob(['[!a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Glob(['^[^a-z]file.txt']))->isValid('^1file.txt')); + $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Glob(['[!a-z0-9]file.txt']))->isValid('#file.txt')); + $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('afile.txt')); + $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('5file.txt')); + } + + public function testCaretNegatedCharacterClass(): void + { + $this->assertTrue((new Glob(['[^a-z]file.txt']))->isValid('1file.txt')); + $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); + } + + public function testSpecialCharsInsideCharacterClasses(): void + { + $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file.name.txt')); + $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file+name.txt')); + $this->assertFalse((new Glob(['file[.+]name.txt']))->isValid('filename.txt')); + $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('_special.txt')); + $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('@special.txt')); + $this->assertFalse((new Glob(['[_!@#]special.txt']))->isValid('xspecial.txt')); + $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('-dash.txt')); + $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('adash.txt')); + $this->assertTrue((new Glob(['[abc-]dash.txt']))->isValid('-dash.txt')); + } + + public function testCharacterClassCombinedWithGlobstar(): void + { + $this->assertTrue((new Glob(['[a-z]*.txt']))->isValid('abc.txt')); + $this->assertFalse((new Glob(['[a-z]*.txt']))->isValid('Abc.txt')); + $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); + $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); + $this->assertFalse((new Glob(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); + $this->assertTrue((new Glob(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); + $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('ab.txt')); + $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); + } + + public function testEdgeCaseEmptyCharacterClass(): void + { + // Empty character class [] matches nothing + $this->assertFalse((new Glob(['[]file.txt']))->isValid('file.txt')); + } + + public function testEdgeCaseUnclosedBracket(): void + { + // Unclosed bracket treated as literal + $this->assertTrue((new Glob(['[abc']))->isValid('[abc')); + $this->assertFalse((new Glob(['[abc']))->isValid('abc')); + } } From 3c5e76c4e6d7714b62b50c27f4dabe0275c083f5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 17:28:37 +0530 Subject: [PATCH 4/8] test: add spec-based parity tests from gregpriday/gitignore-php; fix bracket edge cases and last-match-wins semantics --- SPEC.md | 624 +++++++++++++++++++++++++++++++ src/Validator/Glob.php | 79 ++-- tests/Validator/GlobSpecTest.php | 370 ++++++++++++++++++ 3 files changed, 1052 insertions(+), 21 deletions(-) create mode 100644 SPEC.md create mode 100644 tests/Validator/GlobSpecTest.php diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f5b8dac --- /dev/null +++ b/SPEC.md @@ -0,0 +1,624 @@ +# Glob Pattern Matching Specification + +Derived exhaustively from every test case in the gregpriday/gitignore-php test suite. +Source files: `PatternConverterTest`, `AdvancedPatternConverterTest`, `GitIgnoreManagerTest`, `AdvancedGitIgnoreTest`. + +Sections that test filesystem scanning (reading `.gitignore` files from disk, `SplFileInfo`, directory hierarchies, multiple ignore-file precedence) are marked **out of scope** for a pure pattern validator and omitted from requirements. + +--- + +## 1. Literal matching + +### 1.1 Exact match +- Pattern: `file.txt` +- Subject: `file.txt` +- Expected: match + +### 1.2 Literal non-match with extra suffix +- Pattern: `file.txt` +- Subject: `file.txt.bak` +- Expected: no match + +--- + +## 2. Single wildcard `*` + +### 2.1 Star matches any filename +- Pattern: `*.txt` +- Subject: `file.txt` +- Expected: match + +### 2.2 Star matches any filename (second case) +- Pattern: `*.txt` +- Subject: `another.txt` +- Expected: match + +### 2.3 Star does not match partial extension +- Pattern: `*.txt` +- Subject: `file.txt.bak` +- Expected: no match + +### 2.4 Star does not cross directory boundaries +- Pattern: `*.txt` +- Subject: `dir/file.txt` +- Expected: no match + +### 2.5 Star matches any segment within a path level +- Pattern: `baz/*.txt` +- Subject: `baz/file.txt` +- Expected: match + +### 2.6 Star does not match across directories in path-prefixed pattern +- Pattern: `baz/*.txt` +- Subject: `a/baz/file.txt` +- Expected: no match + +### 2.7 Star does not match non-matching extension +- Pattern: `baz/*.txt` +- Subject: `baz/file.log` +- Expected: no match + +--- + +## 3. Question mark `?` + +### 3.1 Question mark matches single character +- Pattern: `file.?xt` +- Subject: `file.txt` +- Expected: match + +### 3.2 Question mark matches any single character +- Pattern: `file.?xt` +- Subject: `file.dxt` +- Expected: match + +### 3.3 Question mark does not match two characters +- Pattern: `file.?xt` +- Subject: `file.xtt` +- Expected: no match + +### 3.4 Question mark used in directory pattern (single char suffix) +- Pattern: `qux?` +- Subject: `qux1` +- Expected: match + +### 3.5 Question mark matches any single character in suffix +- Pattern: `qux?` +- Subject: `quxa` +- Expected: match + +### 3.6 Question mark requires exactly one character +- Pattern: `qux?` +- Subject: `qux` +- Expected: no match + +### 3.7 Question mark does not match two characters +- Pattern: `qux?` +- Subject: `qux12` +- Expected: no match + +--- + +## 4. Double wildcard `**` + +### 4.1 `**/file` matches file at root (zero leading dirs) +- Pattern: `**/file.txt` +- Subject: `file.txt` +- Expected: match + +### 4.2 `**/file` matches file one directory deep +- Pattern: `**/file.txt` +- Subject: `dir/file.txt` +- Expected: match + +### 4.3 `**/file` matches file in nested directories +- Pattern: `**/file.txt` +- Subject: `dir/subdir/file.txt` +- Expected: match + +### 4.4 `**/file` does not match file with extra extension +- Pattern: `**/file.txt` +- Subject: `file.txt.bak` +- Expected: no match + +### 4.5 `src/**/file` matches file directly under prefix +- Pattern: `src/**/file.txt` +- Subject: `src/file.txt` +- Expected: match + +### 4.6 `src/**/file` matches file one level deep in prefix +- Pattern: `src/**/file.txt` +- Subject: `src/dir/file.txt` +- Expected: match + +### 4.7 `src/**/file` matches file in nested dirs inside prefix +- Pattern: `src/**/file.txt` +- Subject: `src/dir/subdir/file.txt` +- Expected: match + +### 4.8 `src/**/file` does not match outside prefix +- Pattern: `src/**/file.txt` +- Subject: `other/file.txt` +- Expected: no match + +### 4.9 `**/name` matches at root +- Pattern: `**/temp.txt` +- Subject: `temp.txt` +- Expected: match + +### 4.10 `**/name` matches one directory deep +- Pattern: `**/temp.txt` +- Subject: `a/temp.txt` +- Expected: match + +### 4.11 `**/name` matches two directories deep +- Pattern: `**/temp.txt` +- Subject: `a/b/temp.txt` +- Expected: match + +### 4.12 `prefix/**` matches direct child +- Pattern: `src/**` +- Subject: `src/file.txt` +- Expected: match + +### 4.13 `prefix/**` matches nested child +- Pattern: `src/**` +- Subject: `src/a/file.txt` +- Expected: match + +### 4.14 `prefix/**/*.ext` matches direct child with extension +- Pattern: `src/**/*.log` +- Subject: `src/error.log` +- Expected: match + +### 4.15 `prefix/**/*.ext` matches nested child with extension +- Pattern: `src/**/*.log` +- Subject: `src/a/debug.log` +- Expected: match + +### 4.16 `a/**/b/c` matches zero intermediate dirs +- Pattern: `a/**/b/c` +- Subject: `a/b/c` +- Expected: match + +### 4.17 `a/**/b/c` matches one intermediate dir +- Pattern: `a/**/b/c` +- Subject: `a/x/b/c` +- Expected: match + +### 4.18 `a/**/b/c` matches two intermediate dirs +- Pattern: `a/**/b/c` +- Subject: `a/x/y/b/c` +- Expected: match + +### 4.19 `a/**/b/c` does not match wrong tail +- Pattern: `a/**/b/c` +- Subject: `a/b/d` +- Expected: no match + +### 4.20 `**/d/e/**` matches direct children of d/e at root +- Pattern: `**/d/e/**` +- Subject: `d/e/file.txt` +- Expected: match + +### 4.21 `**/d/e/**` matches when d/e is one level deep +- Pattern: `**/d/e/**` +- Subject: `x/d/e/file.txt` +- Expected: match + +### 4.22 `**/d/e/**` matches deep nesting around d/e +- Pattern: `**/d/e/**` +- Subject: `x/y/d/e/z/file.txt` +- Expected: match + +### 4.23 `**/d/e/**` does not match when path differs +- Pattern: `**/d/e/**` +- Subject: `d/f/file.txt` +- Expected: no match + +### 4.24 `prefix/**/*.ext` path wildcard matches nested file +- Pattern: `src/foo/**/*.js` +- Subject: `src/foo/app/file.js` +- Expected: match + +### 4.25 `src/foo/**/*.js` matches direct child +- Pattern: `src/foo/**/*.js` +- Subject: `src/foo/file.js` +- Expected: match + +### 4.26 `src/foo/**/*.js` does not match outside prefix +- Pattern: `src/foo/**/*.js` +- Subject: `src/bar/app/file.js` +- Expected: no match + +### 4.27 Deep nesting with `**/logs/*.log` +- Pattern: `deep/**/logs/*.log` +- Subject: `deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log` +- Expected: match + +--- + +## 5. Escaped characters `\` + +### 5.1 Escaped asterisk matches literal `*` +- Pattern: `file\*.txt` +- Subject: `file*.txt` +- Expected: match + +### 5.2 Escaped asterisk does not match regular character +- Pattern: `file\*.txt` +- Subject: `fileX.txt` +- Expected: no match + +### 5.3 Escaped `#` is not treated as a comment +- Pattern: `\#not_a_comment.txt` +- Subject: `#not_a_comment.txt` +- Expected: match + +### 5.4 Escaped `?` matches literal `?` +- Pattern: `file\?.txt` +- Subject: `file?.txt` +- Expected: match + +--- + +## 6. Brace expansion `{a,b}` + +> **Note:** Brace expansion is a shell glob extension, not part of the gitignore specification. Implementations may choose not to support it. If unsupported, patterns containing `{` should be treated as literals or produce no match for expanded variants. Tests in this section document the gregpriday library's behaviour only; they are **not required** for a gitignore-spec-compliant validator. + +### 6.1 Simple brace expansion — css +- Pattern: `*.{js,css,html}` +- Subject: `style.css` +- Expected: match (if brace expansion supported) + +### 6.2 Simple brace expansion — js +- Pattern: `*.{js,css,html}` +- Subject: `script.js` +- Expected: match (if brace expansion supported) + +### 6.3 Simple brace expansion — html +- Pattern: `*.{js,css,html}` +- Subject: `page.html` +- Expected: match (if brace expansion supported) + +### 6.4 Simple brace expansion — non-member extension +- Pattern: `*.{js,css,html}` +- Subject: `file.txt` +- Expected: no match + +### 6.5 Star in brace pattern still does not cross `/` +- Pattern: `*.{js,css,html}` +- Subject: `dir/style.css` +- Expected: no match + +### 6.6 Brace + `**` — direct child of prefix +- Pattern: `src/**/*.{js,css,html}` +- Subject: `src/style.css` +- Expected: match (if brace expansion supported) + +### 6.7 Brace + `**` — one level deep +- Pattern: `src/**/*.{js,css,html}` +- Subject: `src/js/script.js` +- Expected: match (if brace expansion supported) + +### 6.8 Brace + `**` — nested dirs +- Pattern: `src/**/*.{js,css,html}` +- Subject: `src/views/components/page.html` +- Expected: match (if brace expansion supported) + +### 6.9 Brace + `**` — wrong extension +- Pattern: `src/**/*.{js,css,html}` +- Subject: `src/file.txt` +- Expected: no match + +### 6.10 Brace + `**` — outside prefix +- Pattern: `src/**/*.{js,css,html}` +- Subject: `app/style.css` +- Expected: no match + +### 6.11 Nested brace — src/js/file.js +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `src/js/file.js` +- Expected: match (if brace expansion supported) + +### 6.12 Nested brace — src/js/file.ts +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `src/js/file.ts` +- Expected: match (if brace expansion supported) + +### 6.13 Nested brace — src/js/file.tsx +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `src/js/file.tsx` +- Expected: match (if brace expansion supported) + +### 6.14 Nested brace — app/js/file.js +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `app/js/file.js` +- Expected: match (if brace expansion supported) + +### 6.15 Nested brace — app/js/file.ts +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `app/js/file.ts` +- Expected: match (if brace expansion supported) + +### 6.16 Nested brace — wrong top-level dir +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `lib/js/file.js` +- Expected: no match + +### 6.17 Nested brace — wrong extension +- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` +- Subject: `src/js/file.css` +- Expected: no match + +--- + +## 7. Basic character classes `[...]` + +### 7.1 Single character class matches +- Pattern: `[a]bc.txt` +- Subject: `abc.txt` +- Expected: match + +### 7.2 Single character class does not match other char +- Pattern: `[a]bc.txt` +- Subject: `bbc.txt` +- Expected: no match + +### 7.3 Range `[a-z]` matches lowercase letter +- Pattern: `[a-z]est.txt` +- Subject: `test.txt` +- Expected: match + +### 7.4 Range `[a-z]` does not match uppercase +- Pattern: `[a-z]est.txt` +- Subject: `Test.txt` +- Expected: no match + +### 7.5 Range `[A-Z]` matches uppercase letter +- Pattern: `[A-Z]est.txt` +- Subject: `Test.txt` +- Expected: match + +### 7.6 Range `[A-Z]` does not match lowercase +- Pattern: `[A-Z]est.txt` +- Subject: `test.txt` +- Expected: no match + +### 7.7 Numeric range `[0-9]` matches digit +- Pattern: `file[0-9].log` +- Subject: `file5.log` +- Expected: match + +### 7.8 Numeric range `[0-9]` does not match letter +- Pattern: `file[0-9].log` +- Subject: `fileA.log` +- Expected: no match + +### 7.9 Combined ranges `[a-zA-Z]` matches lowercase +- Pattern: `[a-zA-Z]file.txt` +- Subject: `afile.txt` +- Expected: match + +### 7.10 Combined ranges `[a-zA-Z]` matches uppercase +- Pattern: `[a-zA-Z]file.txt` +- Subject: `Afile.txt` +- Expected: match + +### 7.11 Combined ranges `[a-zA-Z]` does not match digit +- Pattern: `[a-zA-Z]file.txt` +- Subject: `1file.txt` +- Expected: no match + +--- + +## 8. Negated character classes `[!...]` / `[^...]` + +### 8.1 `[!a-z]` matches non-lowercase +- Pattern: `[!a-z]file.txt` +- Subject: `Afile.txt` +- Expected: match + +### 8.2 `[!a-z]` does not match lowercase +- Pattern: `[!a-z]file.txt` +- Subject: `afile.txt` +- Expected: no match + +### 8.3 `[^a-z]` matches digit (caret negation syntax) +- Pattern: `[^a-z]file.txt` +- Subject: `1file.txt` +- Expected: match + +### 8.4 `[^a-z]` does not match lowercase (caret syntax) +- Pattern: `[^a-z]file.txt` +- Subject: `afile.txt` +- Expected: no match + +### 8.5 `[!a-z0-9]` matches special character +- Pattern: `[!a-z0-9]file.txt` +- Subject: `#file.txt` +- Expected: match + +### 8.6 `[!a-z0-9]` does not match lowercase +- Pattern: `[!a-z0-9]file.txt` +- Subject: `afile.txt` +- Expected: no match + +### 8.7 `[!a-z0-9]` does not match digit +- Pattern: `[!a-z0-9]file.txt` +- Subject: `5file.txt` +- Expected: no match + +--- + +## 9. Special characters inside classes + +### 9.1 `[.+]` matches literal dot +- Pattern: `file[.+]name.txt` +- Subject: `file.name.txt` +- Expected: match + +### 9.2 `[.+]` matches literal plus +- Pattern: `file[.+]name.txt` +- Subject: `file+name.txt` +- Expected: match + +### 9.3 `[.+]` does not match empty +- Pattern: `file[.+]name.txt` +- Subject: `filename.txt` +- Expected: no match + +### 9.4 `[_!@#]` matches underscore +- Pattern: `[_!@#]special.txt` +- Subject: `_special.txt` +- Expected: match + +### 9.5 `[_!@#]` matches at-sign +- Pattern: `[_!@#]special.txt` +- Subject: `@special.txt` +- Expected: match + +### 9.6 `[_!@#]` does not match unlisted char +- Pattern: `[_!@#]special.txt` +- Subject: `xspecial.txt` +- Expected: no match + +### 9.7 Dash at start `[-abc]` matches literal dash +- Pattern: `[-abc]dash.txt` +- Subject: `-dash.txt` +- Expected: match + +### 9.8 Dash at start `[-abc]` matches listed char +- Pattern: `[-abc]dash.txt` +- Subject: `adash.txt` +- Expected: match + +### 9.9 Dash at end `[abc-]` matches literal dash +- Pattern: `[abc-]dash.txt` +- Subject: `-dash.txt` +- Expected: match + +--- + +## 10. Character classes combined with other features + +### 10.1 `[a-z]*` matches lowercase-prefixed filename +- Pattern: `[a-z]*.txt` +- Subject: `abc.txt` +- Expected: match + +### 10.2 `[a-z]*` does not match uppercase-prefixed filename +- Pattern: `[a-z]*.txt` +- Subject: `Abc.txt` +- Expected: no match + +### 10.3 `**/[a-z]*` matches lowercase file in subdir +- Pattern: `**/[a-z]*.txt` +- Subject: `dir/abc.txt` +- Expected: match + +### 10.4 `**/[a-z]*` matches lowercase file deeply nested +- Pattern: `**/[a-z]*.txt` +- Subject: `dir/subdir/abc.txt` +- Expected: match + +### 10.5 `**/[a-z]*` does not match uppercase file in subdir +- Pattern: `**/[a-z]*.txt` +- Subject: `dir/Abc.txt` +- Expected: no match + +### 10.6 Multiple character classes — both constraints satisfied +- Pattern: `[a-z][0-9]*.txt` +- Subject: `a1file.txt` +- Expected: match + +### 10.7 Multiple character classes — digit constraint fails +- Pattern: `[a-z][0-9]*.txt` +- Subject: `ab.txt` +- Expected: no match + +### 10.8 Multiple character classes — letter constraint fails +- Pattern: `[a-z][0-9]*.txt` +- Subject: `A1file.txt` +- Expected: no match + +--- + +## 11. Character class edge cases + +### 11.1 Empty class `[]` matches nothing +- Pattern: `[]file.txt` +- Subject: `file.txt` +- Expected: no match +- Notes: `[]` has no valid character class content; the `[` should not cause a match on the bare filename. + +### 11.2 Unclosed bracket is treated as literal characters +- Pattern: `[abc` +- Subject: `[abc` +- Expected: match +- Notes: No closing `]` found; the whole token is treated as literal characters. + +### 11.3 Unclosed bracket does not match without the bracket +- Pattern: `[abc` +- Subject: `abc` +- Expected: no match + +### 11.4 `[!]` — exclamation as only content, should match `!` +- Pattern: `[!]file.txt` +- Subject: `!file.txt` +- Expected: match +- Notes: `]` is the first char after `!`, making it part of the class rather than the closing bracket; the class ends up matching `!` literally. Behaviour is implementation-defined; some implementations treat this as an unclosed class. + +### 11.5 `[^]` — caret as only content, should match `^` +- Pattern: `[^]file.txt` +- Subject: `^file.txt` +- Expected: match +- Notes: Same edge-case reasoning as 11.4. + +--- + +## 12. Complex / real-world patterns + +### 12.1 README negation chain — ignore all `.md` +- Pattern list: `*.md`, `!README*.md`, `README-private*.md` +- Subject: `documentation.md` +- Expected: excluded (matched by `*.md`, not re-included) + +### 12.2 README negation chain — keep README +- Pattern list: `*.md`, `!README*.md`, `README-private*.md` +- Subject: `README.md` +- Expected: included (re-included by `!README*.md`) + +### 12.3 README negation chain — keep README-public +- Pattern list: `*.md`, `!README*.md`, `README-private*.md` +- Subject: `README-public.md` +- Expected: included + +### 12.4 README negation chain — ignore README-private +- Pattern list: `*.md`, `!README*.md`, `README-private*.md` +- Subject: `README-private.md` +- Expected: excluded (overridden by `README-private*.md`) + +### 12.5 README negation chain — ignore README-private-draft +- Pattern list: `*.md`, `!README*.md`, `README-private*.md` +- Subject: `README-private-draft.md` +- Expected: excluded + +--- + +## Out of scope + +The following test classes test **filesystem-level behaviour** of `GitIgnoreManager` and are not requirements for a pure pattern validator: + +- Reading `.gitignore` files from disk +- Hierarchical / nested `.gitignore` files with different scopes per directory +- Leading-slash anchoring (pattern `/config` only matches at the directory where the `.gitignore` lives) +- Directory-only patterns (trailing `/`) +- `SplFileInfo` acceptance logic +- Multiple custom ignore-file types (`.dockerignore`, `.customignore`) +- Case-insensitive matching mode +- Whitespace trimming from pattern lines +- Comment lines (`#`) in ignore files +- Quoted patterns (`"..."` in ignore files) +- Unicode filenames (out of scope unless the underlying regex/fnmatch engine supports them natively) +- Multiple-negation counting (`!!` → re-ignore, `!!!` → re-include) diff --git a/src/Validator/Glob.php b/src/Validator/Glob.php index 9cd6070..b4b05af 100644 --- a/src/Validator/Glob.php +++ b/src/Validator/Glob.php @@ -53,11 +53,17 @@ public function isValid($value): bool return true; } - $include = array_filter($this->patterns, fn ($p) => !str_starts_with($p, '!')); - $exclude = array_filter($this->patterns, fn ($p) => str_starts_with($p, '!')); + $hasInclusions = false; + foreach ($this->patterns as $p) { + if (!str_starts_with($p, '!')) { + $hasInclusions = true; + break; + } + } - if (empty($include)) { - foreach ($exclude as $pattern) { + // Pure-exclusion mode: default to valid; any matching exclusion invalidates. + if (!$hasInclusions) { + foreach ($this->patterns as $pattern) { if ($this->match($value, substr($pattern, 1))) { return false; } @@ -66,27 +72,37 @@ public function isValid($value): bool return true; } - $isSpecific = fn ($pattern) => !str_contains($pattern, '*') && !str_contains($pattern, '?') && !str_contains($pattern, '['); + // Inclusion mode. + // + // Step 1 — literal (no *, ?, [) inclusion patterns always win: + // if any specific inclusion matches, the value is valid regardless of later exclusions. + $isWildcard = fn ($p) => str_contains($p, '*') || str_contains($p, '?') || str_contains($p, '['); - foreach ($include as $pattern) { - if ($isSpecific($pattern) && $this->match($value, $pattern)) { + foreach ($this->patterns as $pattern) { + if (!str_starts_with($pattern, '!') && !$isWildcard($pattern) && $this->match($value, $pattern)) { return true; } } - foreach ($exclude as $pattern) { - if ($this->match($value, substr($pattern, 1))) { - return false; - } - } - - foreach ($include as $pattern) { - if (!$isSpecific($pattern) && $this->match($value, $pattern)) { - return true; + // Step 2 — last-match-wins over the remaining (non-literal) patterns: + // non-! wildcard match → valid (true) + // ! match → invalid (false) + // Literal inclusions already handled above; skip them here. + $state = false; + foreach ($this->patterns as $pattern) { + if (str_starts_with($pattern, '!')) { + if ($this->match($value, substr($pattern, 1))) { + $state = false; + } + } elseif ($isWildcard($pattern)) { + if ($this->match($value, $pattern)) { + $state = true; + } } + // literal non-! patterns are skipped (handled in step 1) } - return false; + return $state; } /** @@ -143,13 +159,29 @@ private function matchGlobstar(string $subject, string $pattern): bool } elseif ($char === '[') { $j = $i + 1; $bracketContent = ''; + $isNegated = false; - // Allow ] as first char inside bracket (or after !) + // Check for negation marker (! or ^) if ($j < $len && ($pattern[$j] === '!' || $pattern[$j] === '^')) { - $bracketContent .= $pattern[$j]; + $negChar = $pattern[$j]; $j++; + // If ] immediately follows the negation marker, treat ! or ^ as a + // literal class member (edge case: [!] or [^]) rather than as negation. + if ($j < $len && $pattern[$j] === ']') { + // Literal class containing only ! or ^ — do NOT treat as negation + $bracketContent .= $negChar; + } else { + $isNegated = true; + $bracketContent .= $negChar; + } } - if ($j < $len && $pattern[$j] === ']') { + + // Allow ] as first char inside bracket class (POSIX rule) + if ($j < $len && $pattern[$j] === ']' && $bracketContent === '') { + $bracketContent .= ']'; + $j++; + } elseif ($j < $len && $pattern[$j] === ']' && $isNegated) { + // After a negation marker, ] as first member is literal $bracketContent .= ']'; $j++; } @@ -162,8 +194,13 @@ private function matchGlobstar(string $subject, string $pattern): bool if ($j < $len) { // Well-formed [...] — normalise ! negation to ^ $inner = $bracketContent; - if (str_starts_with($inner, '!')) { + if ($isNegated && str_starts_with($inner, '!')) { $inner = '^' . substr($inner, 1); + } elseif ($isNegated && str_starts_with($inner, '^')) { + // already ^-prefixed, keep as-is + } elseif (!$isNegated && str_starts_with($inner, '^')) { + // Literal ^ as first char — escape it so PCRE doesn't treat as negation + $inner = '\\^' . substr($inner, 1); } $regex .= '[' . $inner . ']'; $i = $j + 1; diff --git a/tests/Validator/GlobSpecTest.php b/tests/Validator/GlobSpecTest.php new file mode 100644 index 0000000..f5cd4da --- /dev/null +++ b/tests/Validator/GlobSpecTest.php @@ -0,0 +1,370 @@ +assertTrue((new Glob(['file.txt']))->isValid('file.txt')); + + // 1.2 Literal non-match with extra suffix + $this->assertFalse((new Glob(['file.txt']))->isValid('file.txt.bak')); + } + + // ----------------------------------------------------------------------- + // Section 2: Single wildcard * + // ----------------------------------------------------------------------- + + public function testSingleWildcard(): void + { + // 2.1 Star matches any filename + $this->assertTrue((new Glob(['*.txt']))->isValid('file.txt')); + + // 2.2 Star matches any filename (second case) + $this->assertTrue((new Glob(['*.txt']))->isValid('another.txt')); + + // 2.3 Star does not match partial extension + $this->assertFalse((new Glob(['*.txt']))->isValid('file.txt.bak')); + + // 2.4 Star does not cross directory boundaries + $this->assertFalse((new Glob(['*.txt']))->isValid('dir/file.txt')); + + // 2.5 Star matches any segment within a path level + $this->assertTrue((new Glob(['baz/*.txt']))->isValid('baz/file.txt')); + + // 2.6 Star does not match across directories in path-prefixed pattern + $this->assertFalse((new Glob(['baz/*.txt']))->isValid('a/baz/file.txt')); + + // 2.7 Star does not match non-matching extension + $this->assertFalse((new Glob(['baz/*.txt']))->isValid('baz/file.log')); + } + + // ----------------------------------------------------------------------- + // Section 3: Question mark ? + // ----------------------------------------------------------------------- + + public function testQuestionMark(): void + { + // 3.1 Question mark matches single character + $this->assertTrue((new Glob(['file.?xt']))->isValid('file.txt')); + + // 3.2 Question mark matches any single character + $this->assertTrue((new Glob(['file.?xt']))->isValid('file.dxt')); + + // 3.3 Question mark does not match two characters + $this->assertFalse((new Glob(['file.?xt']))->isValid('file.xtt')); + + // 3.4 Question mark used in directory pattern (single char suffix) + $this->assertTrue((new Glob(['qux?']))->isValid('qux1')); + + // 3.5 Question mark matches any single character in suffix + $this->assertTrue((new Glob(['qux?']))->isValid('quxa')); + + // 3.6 Question mark requires exactly one character + $this->assertFalse((new Glob(['qux?']))->isValid('qux')); + + // 3.7 Question mark does not match two characters + $this->assertFalse((new Glob(['qux?']))->isValid('qux12')); + } + + // ----------------------------------------------------------------------- + // Section 4: Double wildcard ** + // ----------------------------------------------------------------------- + + public function testDoubleWildcard(): void + { + // 4.1 **/file matches file at root (zero leading dirs) + $this->assertTrue((new Glob(['**/file.txt']))->isValid('file.txt')); + + // 4.2 **/file matches file one directory deep + $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/file.txt')); + + // 4.3 **/file matches file in nested directories + $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/subdir/file.txt')); + + // 4.4 **/file does not match file with extra extension + $this->assertFalse((new Glob(['**/file.txt']))->isValid('file.txt.bak')); + + // 4.5 src/**/file matches file directly under prefix + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/file.txt')); + + // 4.6 src/**/file matches file one level deep in prefix + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/file.txt')); + + // 4.7 src/**/file matches file in nested dirs inside prefix + $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); + + // 4.8 src/**/file does not match outside prefix + $this->assertFalse((new Glob(['src/**/file.txt']))->isValid('other/file.txt')); + + // 4.9 **/name matches at root + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('temp.txt')); + + // 4.10 **/name matches one directory deep + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/temp.txt')); + + // 4.11 **/name matches two directories deep + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/b/temp.txt')); + + // 4.12 prefix/** matches direct child + $this->assertTrue((new Glob(['src/**']))->isValid('src/file.txt')); + + // 4.13 prefix/** matches nested child + $this->assertTrue((new Glob(['src/**']))->isValid('src/a/file.txt')); + + // 4.14 prefix/**/*.ext matches direct child with extension + $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/error.log')); + + // 4.15 prefix/**/*.ext matches nested child with extension + $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/a/debug.log')); + + // 4.16 a/**/b/c matches zero intermediate dirs + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/b/c')); + + // 4.17 a/**/b/c matches one intermediate dir + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/b/c')); + + // 4.18 a/**/b/c matches two intermediate dirs + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/y/b/c')); + + // 4.19 a/**/b/c does not match wrong tail + $this->assertFalse((new Glob(['a/**/b/c']))->isValid('a/b/d')); + + // 4.20 **/d/e/** matches direct children of d/e at root + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('d/e/file.txt')); + + // 4.21 **/d/e/** matches when d/e is one level deep + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/d/e/file.txt')); + + // 4.22 **/d/e/** matches deep nesting around d/e + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); + + // 4.23 **/d/e/** does not match when path differs + $this->assertFalse((new Glob(['**/d/e/**']))->isValid('d/f/file.txt')); + + // 4.24 prefix/**/*.ext path wildcard matches nested file + $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); + + // 4.25 src/foo/**/*.js matches direct child + $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/file.js')); + + // 4.26 src/foo/**/*.js does not match outside prefix + $this->assertFalse((new Glob(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); + + // 4.27 Deep nesting with **/logs/*.log + $this->assertTrue((new Glob(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); + } + + // ----------------------------------------------------------------------- + // Section 5: Escaped characters + // ----------------------------------------------------------------------- + + public function testEscapedCharacters(): void + { + // 5.1 Escaped asterisk matches literal * + $this->assertTrue((new Glob(['file\*.txt']))->isValid('file*.txt')); + + // 5.2 Escaped asterisk does not match regular character + $this->assertFalse((new Glob(['file\*.txt']))->isValid('fileX.txt')); + + // 5.3 Escaped # is not treated as a comment + $this->assertTrue((new Glob(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); + + // 5.4 Escaped ? matches literal ? + $this->assertTrue((new Glob(['file\?.txt']))->isValid('file?.txt')); + } + + // Section 6: brace expansion — not supported + + // ----------------------------------------------------------------------- + // Section 7: Basic character classes [...] + // ----------------------------------------------------------------------- + + public function testBasicCharacterClasses(): void + { + // 7.1 Single character class matches + $this->assertTrue((new Glob(['[a]bc.txt']))->isValid('abc.txt')); + + // 7.2 Single character class does not match other char + $this->assertFalse((new Glob(['[a]bc.txt']))->isValid('bbc.txt')); + + // 7.3 Range [a-z] matches lowercase letter + $this->assertTrue((new Glob(['[a-z]est.txt']))->isValid('test.txt')); + + // 7.4 Range [a-z] does not match uppercase + $this->assertFalse((new Glob(['[a-z]est.txt']))->isValid('Test.txt')); + + // 7.5 Range [A-Z] matches uppercase letter + $this->assertTrue((new Glob(['[A-Z]est.txt']))->isValid('Test.txt')); + + // 7.6 Range [A-Z] does not match lowercase + $this->assertFalse((new Glob(['[A-Z]est.txt']))->isValid('test.txt')); + + // 7.7 Numeric range [0-9] matches digit + $this->assertTrue((new Glob(['file[0-9].log']))->isValid('file5.log')); + + // 7.8 Numeric range [0-9] does not match letter + $this->assertFalse((new Glob(['file[0-9].log']))->isValid('fileA.log')); + + // 7.9 Combined ranges [a-zA-Z] matches lowercase + $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('afile.txt')); + + // 7.10 Combined ranges [a-zA-Z] matches uppercase + $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); + + // 7.11 Combined ranges [a-zA-Z] does not match digit + $this->assertFalse((new Glob(['[a-zA-Z]file.txt']))->isValid('1file.txt')); + } + + // ----------------------------------------------------------------------- + // Section 8: Negated character classes [!...] / [^...] + // ----------------------------------------------------------------------- + + public function testNegatedCharacterClasses(): void + { + // 8.1 [!a-z] matches non-lowercase + $this->assertTrue((new Glob(['[!a-z]file.txt']))->isValid('Afile.txt')); + + // 8.2 [!a-z] does not match lowercase + $this->assertFalse((new Glob(['[!a-z]file.txt']))->isValid('afile.txt')); + + // 8.3 [^a-z] matches digit (caret negation syntax) + $this->assertTrue((new Glob(['[^a-z]file.txt']))->isValid('1file.txt')); + + // 8.4 [^a-z] does not match lowercase (caret syntax) + $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); + + // 8.5 [!a-z0-9] matches special character + $this->assertTrue((new Glob(['[!a-z0-9]file.txt']))->isValid('#file.txt')); + + // 8.6 [!a-z0-9] does not match lowercase + $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('afile.txt')); + + // 8.7 [!a-z0-9] does not match digit + $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('5file.txt')); + } + + // ----------------------------------------------------------------------- + // Section 9: Special characters inside classes + // ----------------------------------------------------------------------- + + public function testSpecialCharactersInsideClasses(): void + { + // 9.1 [.+] matches literal dot + $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file.name.txt')); + + // 9.2 [.+] matches literal plus + $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file+name.txt')); + + // 9.3 [.+] does not match empty + $this->assertFalse((new Glob(['file[.+]name.txt']))->isValid('filename.txt')); + + // 9.4 [_!@#] matches underscore + $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('_special.txt')); + + // 9.5 [_!@#] matches at-sign + $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('@special.txt')); + + // 9.6 [_!@#] does not match unlisted char + $this->assertFalse((new Glob(['[_!@#]special.txt']))->isValid('xspecial.txt')); + + // 9.7 Dash at start [-abc] matches literal dash + $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('-dash.txt')); + + // 9.8 Dash at start [-abc] matches listed char + $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('adash.txt')); + + // 9.9 Dash at end [abc-] matches literal dash + $this->assertTrue((new Glob(['[abc-]dash.txt']))->isValid('-dash.txt')); + } + + // ----------------------------------------------------------------------- + // Section 10: Character classes combined with other features + // ----------------------------------------------------------------------- + + public function testCharacterClassesCombined(): void + { + // 10.1 [a-z]* matches lowercase-prefixed filename + $this->assertTrue((new Glob(['[a-z]*.txt']))->isValid('abc.txt')); + + // 10.2 [a-z]* does not match uppercase-prefixed filename + $this->assertFalse((new Glob(['[a-z]*.txt']))->isValid('Abc.txt')); + + // 10.3 **/[a-z]* matches lowercase file in subdir + $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); + + // 10.4 **/[a-z]* matches lowercase file deeply nested + $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); + + // 10.5 **/[a-z]* does not match uppercase file in subdir + $this->assertFalse((new Glob(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); + + // 10.6 Multiple character classes — both constraints satisfied + $this->assertTrue((new Glob(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); + + // 10.7 Multiple character classes — digit constraint fails + $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('ab.txt')); + + // 10.8 Multiple character classes — letter constraint fails + $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); + } + + // ----------------------------------------------------------------------- + // Section 11: Character class edge cases + // ----------------------------------------------------------------------- + + public function testCharacterClassEdgeCases(): void + { + // 11.1 Empty class [] matches nothing + $this->assertFalse((new Glob(['[]file.txt']))->isValid('file.txt')); + + // 11.2 Unclosed bracket is treated as literal characters + $this->assertTrue((new Glob(['[abc']))->isValid('[abc')); + + // 11.3 Unclosed bracket does not match without the bracket + $this->assertFalse((new Glob(['[abc']))->isValid('abc')); + + // 11.4 [!] — exclamation as only content, should match ! // edge case: behaviour may be implementation-defined + $this->assertTrue((new Glob(['[!]file.txt']))->isValid('!file.txt')); + + // 11.5 [^] — caret as only content, should match ^ // edge case: behaviour may be implementation-defined + $this->assertTrue((new Glob(['[^]file.txt']))->isValid('^file.txt')); + } + + // ----------------------------------------------------------------------- + // Section 12: Complex / real-world patterns + // ----------------------------------------------------------------------- + + public function testComplexPatterns(): void + { + $patterns = ['*.md', '!README*.md', 'README-private*.md']; + + // 12.1 README negation chain — ignore all .md + // documentation.md matches *.md (inclusion) and is not excluded → valid + $this->assertTrue((new Glob($patterns))->isValid('documentation.md')); + + // 12.2 README negation chain — keep README + // README.md matches *.md (inclusion) but is excluded by !README*.md → invalid + $this->assertFalse((new Glob($patterns))->isValid('README.md')); + + // 12.3 README negation chain — keep README-public + // README-public.md matches *.md but excluded by !README*.md → invalid + $this->assertFalse((new Glob($patterns))->isValid('README-public.md')); + + // 12.4 README negation chain — ignore README-private + // README-private.md: excluded by !README*.md but re-included by README-private*.md → valid + $this->assertTrue((new Glob($patterns))->isValid('README-private.md')); + + // 12.5 README negation chain — ignore README-private-draft + // README-private-draft.md: excluded by !README*.md but re-included by README-private*.md → valid + $this->assertTrue((new Glob($patterns))->isValid('README-private-draft.md')); + } +} From f972dc8aa282f132b91680ebf6ee3404a33de03e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 17:29:20 +0530 Subject: [PATCH 5/8] remove SPEC.md --- SPEC.md | 624 -------------------------------------------------------- 1 file changed, 624 deletions(-) delete mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index f5b8dac..0000000 --- a/SPEC.md +++ /dev/null @@ -1,624 +0,0 @@ -# Glob Pattern Matching Specification - -Derived exhaustively from every test case in the gregpriday/gitignore-php test suite. -Source files: `PatternConverterTest`, `AdvancedPatternConverterTest`, `GitIgnoreManagerTest`, `AdvancedGitIgnoreTest`. - -Sections that test filesystem scanning (reading `.gitignore` files from disk, `SplFileInfo`, directory hierarchies, multiple ignore-file precedence) are marked **out of scope** for a pure pattern validator and omitted from requirements. - ---- - -## 1. Literal matching - -### 1.1 Exact match -- Pattern: `file.txt` -- Subject: `file.txt` -- Expected: match - -### 1.2 Literal non-match with extra suffix -- Pattern: `file.txt` -- Subject: `file.txt.bak` -- Expected: no match - ---- - -## 2. Single wildcard `*` - -### 2.1 Star matches any filename -- Pattern: `*.txt` -- Subject: `file.txt` -- Expected: match - -### 2.2 Star matches any filename (second case) -- Pattern: `*.txt` -- Subject: `another.txt` -- Expected: match - -### 2.3 Star does not match partial extension -- Pattern: `*.txt` -- Subject: `file.txt.bak` -- Expected: no match - -### 2.4 Star does not cross directory boundaries -- Pattern: `*.txt` -- Subject: `dir/file.txt` -- Expected: no match - -### 2.5 Star matches any segment within a path level -- Pattern: `baz/*.txt` -- Subject: `baz/file.txt` -- Expected: match - -### 2.6 Star does not match across directories in path-prefixed pattern -- Pattern: `baz/*.txt` -- Subject: `a/baz/file.txt` -- Expected: no match - -### 2.7 Star does not match non-matching extension -- Pattern: `baz/*.txt` -- Subject: `baz/file.log` -- Expected: no match - ---- - -## 3. Question mark `?` - -### 3.1 Question mark matches single character -- Pattern: `file.?xt` -- Subject: `file.txt` -- Expected: match - -### 3.2 Question mark matches any single character -- Pattern: `file.?xt` -- Subject: `file.dxt` -- Expected: match - -### 3.3 Question mark does not match two characters -- Pattern: `file.?xt` -- Subject: `file.xtt` -- Expected: no match - -### 3.4 Question mark used in directory pattern (single char suffix) -- Pattern: `qux?` -- Subject: `qux1` -- Expected: match - -### 3.5 Question mark matches any single character in suffix -- Pattern: `qux?` -- Subject: `quxa` -- Expected: match - -### 3.6 Question mark requires exactly one character -- Pattern: `qux?` -- Subject: `qux` -- Expected: no match - -### 3.7 Question mark does not match two characters -- Pattern: `qux?` -- Subject: `qux12` -- Expected: no match - ---- - -## 4. Double wildcard `**` - -### 4.1 `**/file` matches file at root (zero leading dirs) -- Pattern: `**/file.txt` -- Subject: `file.txt` -- Expected: match - -### 4.2 `**/file` matches file one directory deep -- Pattern: `**/file.txt` -- Subject: `dir/file.txt` -- Expected: match - -### 4.3 `**/file` matches file in nested directories -- Pattern: `**/file.txt` -- Subject: `dir/subdir/file.txt` -- Expected: match - -### 4.4 `**/file` does not match file with extra extension -- Pattern: `**/file.txt` -- Subject: `file.txt.bak` -- Expected: no match - -### 4.5 `src/**/file` matches file directly under prefix -- Pattern: `src/**/file.txt` -- Subject: `src/file.txt` -- Expected: match - -### 4.6 `src/**/file` matches file one level deep in prefix -- Pattern: `src/**/file.txt` -- Subject: `src/dir/file.txt` -- Expected: match - -### 4.7 `src/**/file` matches file in nested dirs inside prefix -- Pattern: `src/**/file.txt` -- Subject: `src/dir/subdir/file.txt` -- Expected: match - -### 4.8 `src/**/file` does not match outside prefix -- Pattern: `src/**/file.txt` -- Subject: `other/file.txt` -- Expected: no match - -### 4.9 `**/name` matches at root -- Pattern: `**/temp.txt` -- Subject: `temp.txt` -- Expected: match - -### 4.10 `**/name` matches one directory deep -- Pattern: `**/temp.txt` -- Subject: `a/temp.txt` -- Expected: match - -### 4.11 `**/name` matches two directories deep -- Pattern: `**/temp.txt` -- Subject: `a/b/temp.txt` -- Expected: match - -### 4.12 `prefix/**` matches direct child -- Pattern: `src/**` -- Subject: `src/file.txt` -- Expected: match - -### 4.13 `prefix/**` matches nested child -- Pattern: `src/**` -- Subject: `src/a/file.txt` -- Expected: match - -### 4.14 `prefix/**/*.ext` matches direct child with extension -- Pattern: `src/**/*.log` -- Subject: `src/error.log` -- Expected: match - -### 4.15 `prefix/**/*.ext` matches nested child with extension -- Pattern: `src/**/*.log` -- Subject: `src/a/debug.log` -- Expected: match - -### 4.16 `a/**/b/c` matches zero intermediate dirs -- Pattern: `a/**/b/c` -- Subject: `a/b/c` -- Expected: match - -### 4.17 `a/**/b/c` matches one intermediate dir -- Pattern: `a/**/b/c` -- Subject: `a/x/b/c` -- Expected: match - -### 4.18 `a/**/b/c` matches two intermediate dirs -- Pattern: `a/**/b/c` -- Subject: `a/x/y/b/c` -- Expected: match - -### 4.19 `a/**/b/c` does not match wrong tail -- Pattern: `a/**/b/c` -- Subject: `a/b/d` -- Expected: no match - -### 4.20 `**/d/e/**` matches direct children of d/e at root -- Pattern: `**/d/e/**` -- Subject: `d/e/file.txt` -- Expected: match - -### 4.21 `**/d/e/**` matches when d/e is one level deep -- Pattern: `**/d/e/**` -- Subject: `x/d/e/file.txt` -- Expected: match - -### 4.22 `**/d/e/**` matches deep nesting around d/e -- Pattern: `**/d/e/**` -- Subject: `x/y/d/e/z/file.txt` -- Expected: match - -### 4.23 `**/d/e/**` does not match when path differs -- Pattern: `**/d/e/**` -- Subject: `d/f/file.txt` -- Expected: no match - -### 4.24 `prefix/**/*.ext` path wildcard matches nested file -- Pattern: `src/foo/**/*.js` -- Subject: `src/foo/app/file.js` -- Expected: match - -### 4.25 `src/foo/**/*.js` matches direct child -- Pattern: `src/foo/**/*.js` -- Subject: `src/foo/file.js` -- Expected: match - -### 4.26 `src/foo/**/*.js` does not match outside prefix -- Pattern: `src/foo/**/*.js` -- Subject: `src/bar/app/file.js` -- Expected: no match - -### 4.27 Deep nesting with `**/logs/*.log` -- Pattern: `deep/**/logs/*.log` -- Subject: `deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log` -- Expected: match - ---- - -## 5. Escaped characters `\` - -### 5.1 Escaped asterisk matches literal `*` -- Pattern: `file\*.txt` -- Subject: `file*.txt` -- Expected: match - -### 5.2 Escaped asterisk does not match regular character -- Pattern: `file\*.txt` -- Subject: `fileX.txt` -- Expected: no match - -### 5.3 Escaped `#` is not treated as a comment -- Pattern: `\#not_a_comment.txt` -- Subject: `#not_a_comment.txt` -- Expected: match - -### 5.4 Escaped `?` matches literal `?` -- Pattern: `file\?.txt` -- Subject: `file?.txt` -- Expected: match - ---- - -## 6. Brace expansion `{a,b}` - -> **Note:** Brace expansion is a shell glob extension, not part of the gitignore specification. Implementations may choose not to support it. If unsupported, patterns containing `{` should be treated as literals or produce no match for expanded variants. Tests in this section document the gregpriday library's behaviour only; they are **not required** for a gitignore-spec-compliant validator. - -### 6.1 Simple brace expansion — css -- Pattern: `*.{js,css,html}` -- Subject: `style.css` -- Expected: match (if brace expansion supported) - -### 6.2 Simple brace expansion — js -- Pattern: `*.{js,css,html}` -- Subject: `script.js` -- Expected: match (if brace expansion supported) - -### 6.3 Simple brace expansion — html -- Pattern: `*.{js,css,html}` -- Subject: `page.html` -- Expected: match (if brace expansion supported) - -### 6.4 Simple brace expansion — non-member extension -- Pattern: `*.{js,css,html}` -- Subject: `file.txt` -- Expected: no match - -### 6.5 Star in brace pattern still does not cross `/` -- Pattern: `*.{js,css,html}` -- Subject: `dir/style.css` -- Expected: no match - -### 6.6 Brace + `**` — direct child of prefix -- Pattern: `src/**/*.{js,css,html}` -- Subject: `src/style.css` -- Expected: match (if brace expansion supported) - -### 6.7 Brace + `**` — one level deep -- Pattern: `src/**/*.{js,css,html}` -- Subject: `src/js/script.js` -- Expected: match (if brace expansion supported) - -### 6.8 Brace + `**` — nested dirs -- Pattern: `src/**/*.{js,css,html}` -- Subject: `src/views/components/page.html` -- Expected: match (if brace expansion supported) - -### 6.9 Brace + `**` — wrong extension -- Pattern: `src/**/*.{js,css,html}` -- Subject: `src/file.txt` -- Expected: no match - -### 6.10 Brace + `**` — outside prefix -- Pattern: `src/**/*.{js,css,html}` -- Subject: `app/style.css` -- Expected: no match - -### 6.11 Nested brace — src/js/file.js -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `src/js/file.js` -- Expected: match (if brace expansion supported) - -### 6.12 Nested brace — src/js/file.ts -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `src/js/file.ts` -- Expected: match (if brace expansion supported) - -### 6.13 Nested brace — src/js/file.tsx -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `src/js/file.tsx` -- Expected: match (if brace expansion supported) - -### 6.14 Nested brace — app/js/file.js -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `app/js/file.js` -- Expected: match (if brace expansion supported) - -### 6.15 Nested brace — app/js/file.ts -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `app/js/file.ts` -- Expected: match (if brace expansion supported) - -### 6.16 Nested brace — wrong top-level dir -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `lib/js/file.js` -- Expected: no match - -### 6.17 Nested brace — wrong extension -- Pattern: `{src,app}/js/*.{js,{ts,tsx}}` -- Subject: `src/js/file.css` -- Expected: no match - ---- - -## 7. Basic character classes `[...]` - -### 7.1 Single character class matches -- Pattern: `[a]bc.txt` -- Subject: `abc.txt` -- Expected: match - -### 7.2 Single character class does not match other char -- Pattern: `[a]bc.txt` -- Subject: `bbc.txt` -- Expected: no match - -### 7.3 Range `[a-z]` matches lowercase letter -- Pattern: `[a-z]est.txt` -- Subject: `test.txt` -- Expected: match - -### 7.4 Range `[a-z]` does not match uppercase -- Pattern: `[a-z]est.txt` -- Subject: `Test.txt` -- Expected: no match - -### 7.5 Range `[A-Z]` matches uppercase letter -- Pattern: `[A-Z]est.txt` -- Subject: `Test.txt` -- Expected: match - -### 7.6 Range `[A-Z]` does not match lowercase -- Pattern: `[A-Z]est.txt` -- Subject: `test.txt` -- Expected: no match - -### 7.7 Numeric range `[0-9]` matches digit -- Pattern: `file[0-9].log` -- Subject: `file5.log` -- Expected: match - -### 7.8 Numeric range `[0-9]` does not match letter -- Pattern: `file[0-9].log` -- Subject: `fileA.log` -- Expected: no match - -### 7.9 Combined ranges `[a-zA-Z]` matches lowercase -- Pattern: `[a-zA-Z]file.txt` -- Subject: `afile.txt` -- Expected: match - -### 7.10 Combined ranges `[a-zA-Z]` matches uppercase -- Pattern: `[a-zA-Z]file.txt` -- Subject: `Afile.txt` -- Expected: match - -### 7.11 Combined ranges `[a-zA-Z]` does not match digit -- Pattern: `[a-zA-Z]file.txt` -- Subject: `1file.txt` -- Expected: no match - ---- - -## 8. Negated character classes `[!...]` / `[^...]` - -### 8.1 `[!a-z]` matches non-lowercase -- Pattern: `[!a-z]file.txt` -- Subject: `Afile.txt` -- Expected: match - -### 8.2 `[!a-z]` does not match lowercase -- Pattern: `[!a-z]file.txt` -- Subject: `afile.txt` -- Expected: no match - -### 8.3 `[^a-z]` matches digit (caret negation syntax) -- Pattern: `[^a-z]file.txt` -- Subject: `1file.txt` -- Expected: match - -### 8.4 `[^a-z]` does not match lowercase (caret syntax) -- Pattern: `[^a-z]file.txt` -- Subject: `afile.txt` -- Expected: no match - -### 8.5 `[!a-z0-9]` matches special character -- Pattern: `[!a-z0-9]file.txt` -- Subject: `#file.txt` -- Expected: match - -### 8.6 `[!a-z0-9]` does not match lowercase -- Pattern: `[!a-z0-9]file.txt` -- Subject: `afile.txt` -- Expected: no match - -### 8.7 `[!a-z0-9]` does not match digit -- Pattern: `[!a-z0-9]file.txt` -- Subject: `5file.txt` -- Expected: no match - ---- - -## 9. Special characters inside classes - -### 9.1 `[.+]` matches literal dot -- Pattern: `file[.+]name.txt` -- Subject: `file.name.txt` -- Expected: match - -### 9.2 `[.+]` matches literal plus -- Pattern: `file[.+]name.txt` -- Subject: `file+name.txt` -- Expected: match - -### 9.3 `[.+]` does not match empty -- Pattern: `file[.+]name.txt` -- Subject: `filename.txt` -- Expected: no match - -### 9.4 `[_!@#]` matches underscore -- Pattern: `[_!@#]special.txt` -- Subject: `_special.txt` -- Expected: match - -### 9.5 `[_!@#]` matches at-sign -- Pattern: `[_!@#]special.txt` -- Subject: `@special.txt` -- Expected: match - -### 9.6 `[_!@#]` does not match unlisted char -- Pattern: `[_!@#]special.txt` -- Subject: `xspecial.txt` -- Expected: no match - -### 9.7 Dash at start `[-abc]` matches literal dash -- Pattern: `[-abc]dash.txt` -- Subject: `-dash.txt` -- Expected: match - -### 9.8 Dash at start `[-abc]` matches listed char -- Pattern: `[-abc]dash.txt` -- Subject: `adash.txt` -- Expected: match - -### 9.9 Dash at end `[abc-]` matches literal dash -- Pattern: `[abc-]dash.txt` -- Subject: `-dash.txt` -- Expected: match - ---- - -## 10. Character classes combined with other features - -### 10.1 `[a-z]*` matches lowercase-prefixed filename -- Pattern: `[a-z]*.txt` -- Subject: `abc.txt` -- Expected: match - -### 10.2 `[a-z]*` does not match uppercase-prefixed filename -- Pattern: `[a-z]*.txt` -- Subject: `Abc.txt` -- Expected: no match - -### 10.3 `**/[a-z]*` matches lowercase file in subdir -- Pattern: `**/[a-z]*.txt` -- Subject: `dir/abc.txt` -- Expected: match - -### 10.4 `**/[a-z]*` matches lowercase file deeply nested -- Pattern: `**/[a-z]*.txt` -- Subject: `dir/subdir/abc.txt` -- Expected: match - -### 10.5 `**/[a-z]*` does not match uppercase file in subdir -- Pattern: `**/[a-z]*.txt` -- Subject: `dir/Abc.txt` -- Expected: no match - -### 10.6 Multiple character classes — both constraints satisfied -- Pattern: `[a-z][0-9]*.txt` -- Subject: `a1file.txt` -- Expected: match - -### 10.7 Multiple character classes — digit constraint fails -- Pattern: `[a-z][0-9]*.txt` -- Subject: `ab.txt` -- Expected: no match - -### 10.8 Multiple character classes — letter constraint fails -- Pattern: `[a-z][0-9]*.txt` -- Subject: `A1file.txt` -- Expected: no match - ---- - -## 11. Character class edge cases - -### 11.1 Empty class `[]` matches nothing -- Pattern: `[]file.txt` -- Subject: `file.txt` -- Expected: no match -- Notes: `[]` has no valid character class content; the `[` should not cause a match on the bare filename. - -### 11.2 Unclosed bracket is treated as literal characters -- Pattern: `[abc` -- Subject: `[abc` -- Expected: match -- Notes: No closing `]` found; the whole token is treated as literal characters. - -### 11.3 Unclosed bracket does not match without the bracket -- Pattern: `[abc` -- Subject: `abc` -- Expected: no match - -### 11.4 `[!]` — exclamation as only content, should match `!` -- Pattern: `[!]file.txt` -- Subject: `!file.txt` -- Expected: match -- Notes: `]` is the first char after `!`, making it part of the class rather than the closing bracket; the class ends up matching `!` literally. Behaviour is implementation-defined; some implementations treat this as an unclosed class. - -### 11.5 `[^]` — caret as only content, should match `^` -- Pattern: `[^]file.txt` -- Subject: `^file.txt` -- Expected: match -- Notes: Same edge-case reasoning as 11.4. - ---- - -## 12. Complex / real-world patterns - -### 12.1 README negation chain — ignore all `.md` -- Pattern list: `*.md`, `!README*.md`, `README-private*.md` -- Subject: `documentation.md` -- Expected: excluded (matched by `*.md`, not re-included) - -### 12.2 README negation chain — keep README -- Pattern list: `*.md`, `!README*.md`, `README-private*.md` -- Subject: `README.md` -- Expected: included (re-included by `!README*.md`) - -### 12.3 README negation chain — keep README-public -- Pattern list: `*.md`, `!README*.md`, `README-private*.md` -- Subject: `README-public.md` -- Expected: included - -### 12.4 README negation chain — ignore README-private -- Pattern list: `*.md`, `!README*.md`, `README-private*.md` -- Subject: `README-private.md` -- Expected: excluded (overridden by `README-private*.md`) - -### 12.5 README negation chain — ignore README-private-draft -- Pattern list: `*.md`, `!README*.md`, `README-private*.md` -- Subject: `README-private-draft.md` -- Expected: excluded - ---- - -## Out of scope - -The following test classes test **filesystem-level behaviour** of `GitIgnoreManager` and are not requirements for a pure pattern validator: - -- Reading `.gitignore` files from disk -- Hierarchical / nested `.gitignore` files with different scopes per directory -- Leading-slash anchoring (pattern `/config` only matches at the directory where the `.gitignore` lives) -- Directory-only patterns (trailing `/`) -- `SplFileInfo` acceptance logic -- Multiple custom ignore-file types (`.dockerignore`, `.customignore`) -- Case-insensitive matching mode -- Whitespace trimming from pattern lines -- Comment lines (`#`) in ignore files -- Quoted patterns (`"..."` in ignore files) -- Unicode filenames (out of scope unless the underlying regex/fnmatch engine supports them natively) -- Multiple-negation counting (`!!` → re-ignore, `!!!` → re-include) From b8bcbf6a7f70a5dd442e763ae9188ef74d93a5b4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 17:32:44 +0530 Subject: [PATCH 6/8] refactor: merge GlobSpecTest into GlobTest, delete GlobSpecTest --- tests/Validator/GlobSpecTest.php | 370 ------------------------------- tests/Validator/GlobTest.php | 116 ++++++++++ 2 files changed, 116 insertions(+), 370 deletions(-) delete mode 100644 tests/Validator/GlobSpecTest.php diff --git a/tests/Validator/GlobSpecTest.php b/tests/Validator/GlobSpecTest.php deleted file mode 100644 index f5cd4da..0000000 --- a/tests/Validator/GlobSpecTest.php +++ /dev/null @@ -1,370 +0,0 @@ -assertTrue((new Glob(['file.txt']))->isValid('file.txt')); - - // 1.2 Literal non-match with extra suffix - $this->assertFalse((new Glob(['file.txt']))->isValid('file.txt.bak')); - } - - // ----------------------------------------------------------------------- - // Section 2: Single wildcard * - // ----------------------------------------------------------------------- - - public function testSingleWildcard(): void - { - // 2.1 Star matches any filename - $this->assertTrue((new Glob(['*.txt']))->isValid('file.txt')); - - // 2.2 Star matches any filename (second case) - $this->assertTrue((new Glob(['*.txt']))->isValid('another.txt')); - - // 2.3 Star does not match partial extension - $this->assertFalse((new Glob(['*.txt']))->isValid('file.txt.bak')); - - // 2.4 Star does not cross directory boundaries - $this->assertFalse((new Glob(['*.txt']))->isValid('dir/file.txt')); - - // 2.5 Star matches any segment within a path level - $this->assertTrue((new Glob(['baz/*.txt']))->isValid('baz/file.txt')); - - // 2.6 Star does not match across directories in path-prefixed pattern - $this->assertFalse((new Glob(['baz/*.txt']))->isValid('a/baz/file.txt')); - - // 2.7 Star does not match non-matching extension - $this->assertFalse((new Glob(['baz/*.txt']))->isValid('baz/file.log')); - } - - // ----------------------------------------------------------------------- - // Section 3: Question mark ? - // ----------------------------------------------------------------------- - - public function testQuestionMark(): void - { - // 3.1 Question mark matches single character - $this->assertTrue((new Glob(['file.?xt']))->isValid('file.txt')); - - // 3.2 Question mark matches any single character - $this->assertTrue((new Glob(['file.?xt']))->isValid('file.dxt')); - - // 3.3 Question mark does not match two characters - $this->assertFalse((new Glob(['file.?xt']))->isValid('file.xtt')); - - // 3.4 Question mark used in directory pattern (single char suffix) - $this->assertTrue((new Glob(['qux?']))->isValid('qux1')); - - // 3.5 Question mark matches any single character in suffix - $this->assertTrue((new Glob(['qux?']))->isValid('quxa')); - - // 3.6 Question mark requires exactly one character - $this->assertFalse((new Glob(['qux?']))->isValid('qux')); - - // 3.7 Question mark does not match two characters - $this->assertFalse((new Glob(['qux?']))->isValid('qux12')); - } - - // ----------------------------------------------------------------------- - // Section 4: Double wildcard ** - // ----------------------------------------------------------------------- - - public function testDoubleWildcard(): void - { - // 4.1 **/file matches file at root (zero leading dirs) - $this->assertTrue((new Glob(['**/file.txt']))->isValid('file.txt')); - - // 4.2 **/file matches file one directory deep - $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/file.txt')); - - // 4.3 **/file matches file in nested directories - $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/subdir/file.txt')); - - // 4.4 **/file does not match file with extra extension - $this->assertFalse((new Glob(['**/file.txt']))->isValid('file.txt.bak')); - - // 4.5 src/**/file matches file directly under prefix - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/file.txt')); - - // 4.6 src/**/file matches file one level deep in prefix - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/file.txt')); - - // 4.7 src/**/file matches file in nested dirs inside prefix - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); - - // 4.8 src/**/file does not match outside prefix - $this->assertFalse((new Glob(['src/**/file.txt']))->isValid('other/file.txt')); - - // 4.9 **/name matches at root - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('temp.txt')); - - // 4.10 **/name matches one directory deep - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/temp.txt')); - - // 4.11 **/name matches two directories deep - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/b/temp.txt')); - - // 4.12 prefix/** matches direct child - $this->assertTrue((new Glob(['src/**']))->isValid('src/file.txt')); - - // 4.13 prefix/** matches nested child - $this->assertTrue((new Glob(['src/**']))->isValid('src/a/file.txt')); - - // 4.14 prefix/**/*.ext matches direct child with extension - $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/error.log')); - - // 4.15 prefix/**/*.ext matches nested child with extension - $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/a/debug.log')); - - // 4.16 a/**/b/c matches zero intermediate dirs - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/b/c')); - - // 4.17 a/**/b/c matches one intermediate dir - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/b/c')); - - // 4.18 a/**/b/c matches two intermediate dirs - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/y/b/c')); - - // 4.19 a/**/b/c does not match wrong tail - $this->assertFalse((new Glob(['a/**/b/c']))->isValid('a/b/d')); - - // 4.20 **/d/e/** matches direct children of d/e at root - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('d/e/file.txt')); - - // 4.21 **/d/e/** matches when d/e is one level deep - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/d/e/file.txt')); - - // 4.22 **/d/e/** matches deep nesting around d/e - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); - - // 4.23 **/d/e/** does not match when path differs - $this->assertFalse((new Glob(['**/d/e/**']))->isValid('d/f/file.txt')); - - // 4.24 prefix/**/*.ext path wildcard matches nested file - $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); - - // 4.25 src/foo/**/*.js matches direct child - $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/file.js')); - - // 4.26 src/foo/**/*.js does not match outside prefix - $this->assertFalse((new Glob(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); - - // 4.27 Deep nesting with **/logs/*.log - $this->assertTrue((new Glob(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); - } - - // ----------------------------------------------------------------------- - // Section 5: Escaped characters - // ----------------------------------------------------------------------- - - public function testEscapedCharacters(): void - { - // 5.1 Escaped asterisk matches literal * - $this->assertTrue((new Glob(['file\*.txt']))->isValid('file*.txt')); - - // 5.2 Escaped asterisk does not match regular character - $this->assertFalse((new Glob(['file\*.txt']))->isValid('fileX.txt')); - - // 5.3 Escaped # is not treated as a comment - $this->assertTrue((new Glob(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); - - // 5.4 Escaped ? matches literal ? - $this->assertTrue((new Glob(['file\?.txt']))->isValid('file?.txt')); - } - - // Section 6: brace expansion — not supported - - // ----------------------------------------------------------------------- - // Section 7: Basic character classes [...] - // ----------------------------------------------------------------------- - - public function testBasicCharacterClasses(): void - { - // 7.1 Single character class matches - $this->assertTrue((new Glob(['[a]bc.txt']))->isValid('abc.txt')); - - // 7.2 Single character class does not match other char - $this->assertFalse((new Glob(['[a]bc.txt']))->isValid('bbc.txt')); - - // 7.3 Range [a-z] matches lowercase letter - $this->assertTrue((new Glob(['[a-z]est.txt']))->isValid('test.txt')); - - // 7.4 Range [a-z] does not match uppercase - $this->assertFalse((new Glob(['[a-z]est.txt']))->isValid('Test.txt')); - - // 7.5 Range [A-Z] matches uppercase letter - $this->assertTrue((new Glob(['[A-Z]est.txt']))->isValid('Test.txt')); - - // 7.6 Range [A-Z] does not match lowercase - $this->assertFalse((new Glob(['[A-Z]est.txt']))->isValid('test.txt')); - - // 7.7 Numeric range [0-9] matches digit - $this->assertTrue((new Glob(['file[0-9].log']))->isValid('file5.log')); - - // 7.8 Numeric range [0-9] does not match letter - $this->assertFalse((new Glob(['file[0-9].log']))->isValid('fileA.log')); - - // 7.9 Combined ranges [a-zA-Z] matches lowercase - $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('afile.txt')); - - // 7.10 Combined ranges [a-zA-Z] matches uppercase - $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); - - // 7.11 Combined ranges [a-zA-Z] does not match digit - $this->assertFalse((new Glob(['[a-zA-Z]file.txt']))->isValid('1file.txt')); - } - - // ----------------------------------------------------------------------- - // Section 8: Negated character classes [!...] / [^...] - // ----------------------------------------------------------------------- - - public function testNegatedCharacterClasses(): void - { - // 8.1 [!a-z] matches non-lowercase - $this->assertTrue((new Glob(['[!a-z]file.txt']))->isValid('Afile.txt')); - - // 8.2 [!a-z] does not match lowercase - $this->assertFalse((new Glob(['[!a-z]file.txt']))->isValid('afile.txt')); - - // 8.3 [^a-z] matches digit (caret negation syntax) - $this->assertTrue((new Glob(['[^a-z]file.txt']))->isValid('1file.txt')); - - // 8.4 [^a-z] does not match lowercase (caret syntax) - $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); - - // 8.5 [!a-z0-9] matches special character - $this->assertTrue((new Glob(['[!a-z0-9]file.txt']))->isValid('#file.txt')); - - // 8.6 [!a-z0-9] does not match lowercase - $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('afile.txt')); - - // 8.7 [!a-z0-9] does not match digit - $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('5file.txt')); - } - - // ----------------------------------------------------------------------- - // Section 9: Special characters inside classes - // ----------------------------------------------------------------------- - - public function testSpecialCharactersInsideClasses(): void - { - // 9.1 [.+] matches literal dot - $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file.name.txt')); - - // 9.2 [.+] matches literal plus - $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file+name.txt')); - - // 9.3 [.+] does not match empty - $this->assertFalse((new Glob(['file[.+]name.txt']))->isValid('filename.txt')); - - // 9.4 [_!@#] matches underscore - $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('_special.txt')); - - // 9.5 [_!@#] matches at-sign - $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('@special.txt')); - - // 9.6 [_!@#] does not match unlisted char - $this->assertFalse((new Glob(['[_!@#]special.txt']))->isValid('xspecial.txt')); - - // 9.7 Dash at start [-abc] matches literal dash - $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('-dash.txt')); - - // 9.8 Dash at start [-abc] matches listed char - $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('adash.txt')); - - // 9.9 Dash at end [abc-] matches literal dash - $this->assertTrue((new Glob(['[abc-]dash.txt']))->isValid('-dash.txt')); - } - - // ----------------------------------------------------------------------- - // Section 10: Character classes combined with other features - // ----------------------------------------------------------------------- - - public function testCharacterClassesCombined(): void - { - // 10.1 [a-z]* matches lowercase-prefixed filename - $this->assertTrue((new Glob(['[a-z]*.txt']))->isValid('abc.txt')); - - // 10.2 [a-z]* does not match uppercase-prefixed filename - $this->assertFalse((new Glob(['[a-z]*.txt']))->isValid('Abc.txt')); - - // 10.3 **/[a-z]* matches lowercase file in subdir - $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); - - // 10.4 **/[a-z]* matches lowercase file deeply nested - $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); - - // 10.5 **/[a-z]* does not match uppercase file in subdir - $this->assertFalse((new Glob(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); - - // 10.6 Multiple character classes — both constraints satisfied - $this->assertTrue((new Glob(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); - - // 10.7 Multiple character classes — digit constraint fails - $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('ab.txt')); - - // 10.8 Multiple character classes — letter constraint fails - $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); - } - - // ----------------------------------------------------------------------- - // Section 11: Character class edge cases - // ----------------------------------------------------------------------- - - public function testCharacterClassEdgeCases(): void - { - // 11.1 Empty class [] matches nothing - $this->assertFalse((new Glob(['[]file.txt']))->isValid('file.txt')); - - // 11.2 Unclosed bracket is treated as literal characters - $this->assertTrue((new Glob(['[abc']))->isValid('[abc')); - - // 11.3 Unclosed bracket does not match without the bracket - $this->assertFalse((new Glob(['[abc']))->isValid('abc')); - - // 11.4 [!] — exclamation as only content, should match ! // edge case: behaviour may be implementation-defined - $this->assertTrue((new Glob(['[!]file.txt']))->isValid('!file.txt')); - - // 11.5 [^] — caret as only content, should match ^ // edge case: behaviour may be implementation-defined - $this->assertTrue((new Glob(['[^]file.txt']))->isValid('^file.txt')); - } - - // ----------------------------------------------------------------------- - // Section 12: Complex / real-world patterns - // ----------------------------------------------------------------------- - - public function testComplexPatterns(): void - { - $patterns = ['*.md', '!README*.md', 'README-private*.md']; - - // 12.1 README negation chain — ignore all .md - // documentation.md matches *.md (inclusion) and is not excluded → valid - $this->assertTrue((new Glob($patterns))->isValid('documentation.md')); - - // 12.2 README negation chain — keep README - // README.md matches *.md (inclusion) but is excluded by !README*.md → invalid - $this->assertFalse((new Glob($patterns))->isValid('README.md')); - - // 12.3 README negation chain — keep README-public - // README-public.md matches *.md but excluded by !README*.md → invalid - $this->assertFalse((new Glob($patterns))->isValid('README-public.md')); - - // 12.4 README negation chain — ignore README-private - // README-private.md: excluded by !README*.md but re-included by README-private*.md → valid - $this->assertTrue((new Glob($patterns))->isValid('README-private.md')); - - // 12.5 README negation chain — ignore README-private-draft - // README-private-draft.md: excluded by !README*.md but re-included by README-private*.md → valid - $this->assertTrue((new Glob($patterns))->isValid('README-private-draft.md')); - } -} diff --git a/tests/Validator/GlobTest.php b/tests/Validator/GlobTest.php index 65fe3c0..bf18b15 100644 --- a/tests/Validator/GlobTest.php +++ b/tests/Validator/GlobTest.php @@ -49,6 +49,14 @@ public function testSingleWildcardInclusion(): void $this->assertFalse($validator->isValid('main')); } + public function testSingleWildcardWithDirectoryPrefix(): void + { + // baz/*.txt — * matches within a single directory segment + $this->assertTrue((new Glob(['baz/*.txt']))->isValid('baz/file.txt')); + $this->assertFalse((new Glob(['baz/*.txt']))->isValid('a/baz/file.txt')); // prefix must match literally + $this->assertFalse((new Glob(['baz/*.txt']))->isValid('baz/file.log')); + } + public function testWildcardWithDash(): void { $validator = new Glob(['feature/test-*']); @@ -86,6 +94,15 @@ public function testQuestionMarkMixedWithStar(): void $this->assertFalse($validator->isValid('fix-.php')); // ? requires exactly one char } + public function testQuestionMarkSuffix(): void + { + // qux? — question mark matches exactly one character as suffix + $this->assertTrue((new Glob(['qux?']))->isValid('qux1')); + $this->assertTrue((new Glob(['qux?']))->isValid('quxa')); + $this->assertFalse((new Glob(['qux?']))->isValid('qux')); // requires exactly one char + $this->assertFalse((new Glob(['qux?']))->isValid('qux12')); // does not match two chars + } + public function testDoubleWildcardAtEnd(): void { $validator = new Glob(['src/**']); @@ -307,6 +324,60 @@ public function testDirPrefixDoubleStarExtPattern(): void $this->assertFalse($validator->isValid('src/Foo.js')); } + public function testDoubleWildcardAtStartAlternateFilename(): void + { + // **/temp.txt — different filename from existing **/file.txt tests + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('temp.txt')); + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/temp.txt')); + $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/b/temp.txt')); + } + + public function testDoubleWildcardAtEndNoExtension(): void + { + // src/** without extension filter + $this->assertTrue((new Glob(['src/**']))->isValid('src/file.txt')); + $this->assertTrue((new Glob(['src/**']))->isValid('src/a/file.txt')); + } + + public function testDoubleWildcardLogExtension(): void + { + // src/**/*.log + $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/error.log')); + $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/a/debug.log')); + } + + public function testDoubleWildcardMiddleWithTwoSegmentTail(): void + { + // a/**/b/c — tail is two segments (b/c) + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/b/c')); // zero intermediate + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/b/c')); // one intermediate + $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/y/b/c')); // two intermediate + $this->assertFalse((new Glob(['a/**/b/c']))->isValid('a/b/d')); // wrong tail + } + + public function testDoubleWildcardBothSides(): void + { + // **/d/e/** — globstar on both sides of a literal segment pair + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('d/e/file.txt')); + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/d/e/file.txt')); + $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); + $this->assertFalse((new Glob(['**/d/e/**']))->isValid('d/f/file.txt')); + } + + public function testDoubleWildcardWithJsExtension(): void + { + // src/foo/**/*.js + $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); + $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/file.js')); + $this->assertFalse((new Glob(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); + } + + public function testDoubleWildcardDeepNesting(): void + { + // Very deep path with ** in the middle + $this->assertTrue((new Glob(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); + } + // ------------------------------------------------------------------------- // Dots as literal characters // ------------------------------------------------------------------------- @@ -537,6 +608,18 @@ public function testEscapedAsteriskIsLiteral(): void $this->assertFalse((new Glob(['file\*.txt']))->isValid('fileX.txt')); } + public function testEscapedHashIsNotComment(): void + { + // \# must be treated as a literal # character, not a comment marker + $this->assertTrue((new Glob(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); + } + + public function testEscapedQuestionMarkIsLiteral(): void + { + // \? must match literal ? rather than any single character + $this->assertTrue((new Glob(['file\?.txt']))->isValid('file?.txt')); + } + public function testBasicCharacterClasses(): void { $this->assertTrue((new Glob(['[a]bc.txt']))->isValid('abc.txt')); @@ -606,4 +689,37 @@ public function testEdgeCaseUnclosedBracket(): void $this->assertTrue((new Glob(['[abc']))->isValid('[abc')); $this->assertFalse((new Glob(['[abc']))->isValid('abc')); } + + public function testEdgeCaseExclamationOnlyClass(): void + { + // [!] — exclamation as sole content; behaviour is implementation-defined + // but the implementation treats it as a literal class containing '!' + $this->assertTrue((new Glob(['[!]file.txt']))->isValid('!file.txt')); + } + + public function testEdgeCaseCaretOnlyClass(): void + { + // [^] — caret as sole content; behaviour is implementation-defined + // but the implementation treats it as a literal class containing '^' + $this->assertTrue((new Glob(['[^]file.txt']))->isValid('^file.txt')); + } + + // ------------------------------------------------------------------------- + // Complex negation chain + // ------------------------------------------------------------------------- + + public function testComplexNegationChainPattern(): void + { + // Patterns: *.md, !README*.md, README-private*.md + // - inclusion exists (*.md), so non-matching paths fail + // - README*.md is excluded by the second pattern + // - README-private*.md is re-included by the third pattern + $patterns = ['*.md', '!README*.md', 'README-private*.md']; + + $this->assertTrue((new Glob($patterns))->isValid('documentation.md')); // included, not excluded + $this->assertFalse((new Glob($patterns))->isValid('README.md')); // excluded by !README*.md + $this->assertFalse((new Glob($patterns))->isValid('README-public.md')); // excluded by !README*.md + $this->assertTrue((new Glob($patterns))->isValid('README-private.md')); // re-included by README-private*.md + $this->assertTrue((new Glob($patterns))->isValid('README-private-draft.md')); // re-included by README-private*.md + } } From 8d4bc46dcef72ef0b6bf679d3d3e7d5ee2a03c8f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 17:48:35 +0530 Subject: [PATCH 7/8] fix: replace fnmatch() with matchGlobstar for cross-platform compatibility --- src/Validator/Glob.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Validator/Glob.php b/src/Validator/Glob.php index b4b05af..8527e39 100644 --- a/src/Validator/Glob.php +++ b/src/Validator/Glob.php @@ -127,16 +127,9 @@ public function getType(): string /** * Match a subject against a single pattern. - * Uses fnmatch() for simple patterns without ** or [, regex otherwise. - * Patterns with [ are routed through matchGlobstar to correctly handle - * unclosed brackets (treated as literals) and [!...] negated classes. */ private function match(string $subject, string $pattern): bool { - if (!str_contains($pattern, '**') && !str_contains($pattern, '[')) { - return fnmatch($pattern, $subject, FNM_PATHNAME); - } - return $this->matchGlobstar($subject, $pattern); } From 892c4b66531b43d53e9c047d3a9ef2098afac915 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 21 May 2026 18:03:19 +0530 Subject: [PATCH 8/8] rename: Glob -> Globstar --- src/Validator/{Glob.php => Globstar.php} | 2 +- .../{GlobTest.php => GlobstarTest.php} | 282 +++++++++--------- 2 files changed, 142 insertions(+), 142 deletions(-) rename src/Validator/{Glob.php => Globstar.php} (99%) rename tests/Validator/{GlobTest.php => GlobstarTest.php} (68%) diff --git a/src/Validator/Glob.php b/src/Validator/Globstar.php similarity index 99% rename from src/Validator/Glob.php rename to src/Validator/Globstar.php index 8527e39..2ce6cf3 100644 --- a/src/Validator/Glob.php +++ b/src/Validator/Globstar.php @@ -19,7 +19,7 @@ * Pattern syntax follows the gitignore specification. * See https://git-scm.com/docs/gitignore for reference. */ -class Glob extends Validator +class Globstar extends Validator { public function __construct(private readonly array $patterns) { diff --git a/tests/Validator/GlobTest.php b/tests/Validator/GlobstarTest.php similarity index 68% rename from tests/Validator/GlobTest.php rename to tests/Validator/GlobstarTest.php index bf18b15..eee63e3 100644 --- a/tests/Validator/GlobTest.php +++ b/tests/Validator/GlobstarTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; -class GlobTest extends TestCase +class GlobstarTest extends TestCase { // ------------------------------------------------------------------------- // Empty patterns @@ -12,7 +12,7 @@ class GlobTest extends TestCase public function testEmptyPatternsAlwaysPass(): void { - $validator = new Glob([]); + $validator = new Globstar([]); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('feature/anything')); $this->assertTrue($validator->isValid('src/deep/nested/file.php')); @@ -24,7 +24,7 @@ public function testEmptyPatternsAlwaysPass(): void public function testSingleExactInclusion(): void { - $validator = new Glob(['main']); + $validator = new Globstar(['main']); $this->assertTrue($validator->isValid('main')); $this->assertFalse($validator->isValid('develop')); $this->assertFalse($validator->isValid('main-extra')); @@ -32,7 +32,7 @@ public function testSingleExactInclusion(): void public function testMultipleExactInclusionsOr(): void { - $validator = new Glob(['main', 'develop', 'staging']); + $validator = new Globstar(['main', 'develop', 'staging']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('develop')); $this->assertTrue($validator->isValid('staging')); @@ -42,7 +42,7 @@ public function testMultipleExactInclusionsOr(): void public function testSingleWildcardInclusion(): void { - $validator = new Glob(['feature/*']); + $validator = new Globstar(['feature/*']); $this->assertTrue($validator->isValid('feature/foo')); $this->assertTrue($validator->isValid('feature/bar')); $this->assertFalse($validator->isValid('feature/foo/bar')); // * does not cross / @@ -52,14 +52,14 @@ public function testSingleWildcardInclusion(): void public function testSingleWildcardWithDirectoryPrefix(): void { // baz/*.txt — * matches within a single directory segment - $this->assertTrue((new Glob(['baz/*.txt']))->isValid('baz/file.txt')); - $this->assertFalse((new Glob(['baz/*.txt']))->isValid('a/baz/file.txt')); // prefix must match literally - $this->assertFalse((new Glob(['baz/*.txt']))->isValid('baz/file.log')); + $this->assertTrue((new Globstar(['baz/*.txt']))->isValid('baz/file.txt')); + $this->assertFalse((new Globstar(['baz/*.txt']))->isValid('a/baz/file.txt')); // prefix must match literally + $this->assertFalse((new Globstar(['baz/*.txt']))->isValid('baz/file.log')); } public function testWildcardWithDash(): void { - $validator = new Glob(['feature/test-*']); + $validator = new Globstar(['feature/test-*']); $this->assertTrue($validator->isValid('feature/test-1')); $this->assertTrue($validator->isValid('feature/test-abc')); $this->assertFalse($validator->isValid('feature/other')); @@ -68,7 +68,7 @@ public function testWildcardWithDash(): void public function testQuestionMarkWildcard(): void { - $validator = new Glob(['v?.?']); + $validator = new Globstar(['v?.?']); $this->assertTrue($validator->isValid('v1.0')); $this->assertTrue($validator->isValid('v2.5')); $this->assertFalse($validator->isValid('v10.0')); // ? matches exactly one char, not two @@ -77,7 +77,7 @@ public function testQuestionMarkWildcard(): void public function testQuestionMarkDoesNotCrossSlash(): void { - $validator = new Glob(['feature/?']); + $validator = new Globstar(['feature/?']); $this->assertTrue($validator->isValid('feature/a')); $this->assertTrue($validator->isValid('feature/z')); $this->assertFalse($validator->isValid('feature/ab')); // ? matches only one char @@ -87,7 +87,7 @@ public function testQuestionMarkDoesNotCrossSlash(): void public function testQuestionMarkMixedWithStar(): void { - $validator = new Glob(['fix-?.*']); + $validator = new Globstar(['fix-?.*']); $this->assertTrue($validator->isValid('fix-1.php')); $this->assertTrue($validator->isValid('fix-a.js')); $this->assertFalse($validator->isValid('fix-12.php')); // ? matches only one char @@ -97,15 +97,15 @@ public function testQuestionMarkMixedWithStar(): void public function testQuestionMarkSuffix(): void { // qux? — question mark matches exactly one character as suffix - $this->assertTrue((new Glob(['qux?']))->isValid('qux1')); - $this->assertTrue((new Glob(['qux?']))->isValid('quxa')); - $this->assertFalse((new Glob(['qux?']))->isValid('qux')); // requires exactly one char - $this->assertFalse((new Glob(['qux?']))->isValid('qux12')); // does not match two chars + $this->assertTrue((new Globstar(['qux?']))->isValid('qux1')); + $this->assertTrue((new Globstar(['qux?']))->isValid('quxa')); + $this->assertFalse((new Globstar(['qux?']))->isValid('qux')); // requires exactly one char + $this->assertFalse((new Globstar(['qux?']))->isValid('qux12')); // does not match two chars } public function testDoubleWildcardAtEnd(): void { - $validator = new Glob(['src/**']); + $validator = new Globstar(['src/**']); $this->assertTrue($validator->isValid('src/foo.js')); $this->assertTrue($validator->isValid('src/a/b/c.js')); $this->assertTrue($validator->isValid('src/deep/nested/file.php')); @@ -114,7 +114,7 @@ public function testDoubleWildcardAtEnd(): void public function testDoubleWildcardInMiddle(): void { - $validator = new Glob(['a/**/b']); + $validator = new Globstar(['a/**/b']); $this->assertTrue($validator->isValid('a/b')); // zero intermediate dirs $this->assertTrue($validator->isValid('a/x/b')); // one $this->assertTrue($validator->isValid('a/x/y/b')); // two @@ -124,7 +124,7 @@ public function testDoubleWildcardInMiddle(): void public function testDoubleWildcardAtStart(): void { - $validator = new Glob(['**/foo']); + $validator = new Globstar(['**/foo']); $this->assertTrue($validator->isValid('foo')); // zero leading dirs $this->assertTrue($validator->isValid('a/foo')); // one $this->assertTrue($validator->isValid('a/b/foo')); // two @@ -134,7 +134,7 @@ public function testDoubleWildcardAtStart(): void public function testMixedExactAndWildcardInclusions(): void { - $validator = new Glob(['main', 'feature/*']); + $validator = new Globstar(['main', 'feature/*']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('feature/foo')); $this->assertFalse($validator->isValid('develop')); @@ -147,7 +147,7 @@ public function testMixedExactAndWildcardInclusions(): void public function testSingleExactExclusion(): void { - $validator = new Glob(['!main']); + $validator = new Globstar(['!main']); $this->assertFalse($validator->isValid('main')); $this->assertTrue($validator->isValid('develop')); $this->assertTrue($validator->isValid('feature/foo')); @@ -155,7 +155,7 @@ public function testSingleExactExclusion(): void public function testMultipleExactExclusionsAnd(): void { - $validator = new Glob(['!main', '!develop']); + $validator = new Globstar(['!main', '!develop']); $this->assertFalse($validator->isValid('main')); $this->assertFalse($validator->isValid('develop')); $this->assertTrue($validator->isValid('staging')); @@ -164,7 +164,7 @@ public function testMultipleExactExclusionsAnd(): void public function testWildcardExclusion(): void { - $validator = new Glob(['!feature/*']); + $validator = new Globstar(['!feature/*']); $this->assertFalse($validator->isValid('feature/foo')); $this->assertFalse($validator->isValid('feature/bar')); $this->assertTrue($validator->isValid('main')); @@ -173,7 +173,7 @@ public function testWildcardExclusion(): void public function testDoubleWildcardExclusion(): void { - $validator = new Glob(['!src/**']); + $validator = new Globstar(['!src/**']); $this->assertFalse($validator->isValid('src/foo.js')); $this->assertFalse($validator->isValid('src/a/b/c.js')); $this->assertTrue($validator->isValid('lib/foo.js')); @@ -186,7 +186,7 @@ public function testDoubleWildcardExclusion(): void public function testInclusionTakesPrecedenceWhenBothMatch(): void { - $validator = new Glob(['!feature/*', 'feature/abc']); + $validator = new Globstar(['!feature/*', 'feature/abc']); $this->assertTrue($validator->isValid('feature/abc')); // inclusion wins $this->assertFalse($validator->isValid('feature/xyz')); // only exclusion matches $this->assertFalse($validator->isValid('main')); // no inclusion matches @@ -194,7 +194,7 @@ public function testInclusionTakesPrecedenceWhenBothMatch(): void public function testInclusionWithNoMatchFails(): void { - $validator = new Glob(['main', '!develop']); + $validator = new Globstar(['main', '!develop']); $this->assertTrue($validator->isValid('main')); $this->assertFalse($validator->isValid('develop')); // excluded even if inclusion didn't match $this->assertFalse($validator->isValid('staging')); // no inclusion match @@ -202,7 +202,7 @@ public function testInclusionWithNoMatchFails(): void public function testExclusionBlocksWhenInclusionDoesNotMatch(): void { - $validator = new Glob(['feature/*', '!hotfix/*']); + $validator = new Globstar(['feature/*', '!hotfix/*']); $this->assertTrue($validator->isValid('feature/foo')); $this->assertFalse($validator->isValid('hotfix/urgent')); // no inclusion match, also excluded $this->assertFalse($validator->isValid('main')); // no inclusion match @@ -210,7 +210,7 @@ public function testExclusionBlocksWhenInclusionDoesNotMatch(): void public function testMultipleInclusionsWithSingleExclusion(): void { - $validator = new Glob(['main', 'develop', 'feature/*', '!feature/wip']); + $validator = new Globstar(['main', 'develop', 'feature/*', '!feature/wip']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('develop')); $this->assertTrue($validator->isValid('feature/foo')); @@ -220,7 +220,7 @@ public function testMultipleInclusionsWithSingleExclusion(): void public function testSingleInclusionWithMultipleExclusions(): void { - $validator = new Glob(['feature/**', '!feature/wip', '!feature/experimental']); + $validator = new Globstar(['feature/**', '!feature/wip', '!feature/experimental']); $this->assertTrue($validator->isValid('feature/foo')); $this->assertTrue($validator->isValid('feature/a/b')); $this->assertFalse($validator->isValid('feature/wip')); @@ -230,7 +230,7 @@ public function testSingleInclusionWithMultipleExclusions(): void public function testMultipleInclusionsWithMultipleExclusions(): void { - $validator = new Glob(['main', 'feature/**', '!feature/wip', '!feature/experimental']); + $validator = new Globstar(['main', 'feature/**', '!feature/wip', '!feature/experimental']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('feature/foo')); $this->assertTrue($validator->isValid('feature/a/b')); @@ -241,7 +241,7 @@ public function testMultipleInclusionsWithMultipleExclusions(): void public function testWildcardExclusionOverridesWildcardInclusion(): void { - $validator = new Glob(['src/**', '!src/generated/**']); + $validator = new Globstar(['src/**', '!src/generated/**']); $this->assertTrue($validator->isValid('src/components/Button.php')); $this->assertTrue($validator->isValid('src/utils/helper.js')); $this->assertFalse($validator->isValid('src/generated/Foo.php')); @@ -251,7 +251,7 @@ public function testWildcardExclusionOverridesWildcardInclusion(): void public function testSpecificInclusionOverridesWildcardExclusion(): void { - $validator = new Glob(['feature/hotfix/critical', '!feature/**']); + $validator = new Globstar(['feature/hotfix/critical', '!feature/**']); $this->assertTrue($validator->isValid('feature/hotfix/critical')); // inclusion wins $this->assertFalse($validator->isValid('feature/foo')); $this->assertFalse($validator->isValid('feature/hotfix/other')); @@ -260,7 +260,7 @@ public function testSpecificInclusionOverridesWildcardExclusion(): void public function testOnlyExclusionsDefaultToTrueUnlessExcluded(): void { - $validator = new Glob(['!main', '!develop']); + $validator = new Globstar(['!main', '!develop']); $this->assertFalse($validator->isValid('main')); $this->assertFalse($validator->isValid('develop')); $this->assertTrue($validator->isValid('staging')); @@ -273,7 +273,7 @@ public function testOnlyExclusionsDefaultToTrueUnlessExcluded(): void public function testStarAloneMatchesSingleSegmentOnly(): void { - $validator = new Glob(['*']); + $validator = new Globstar(['*']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('develop')); $this->assertFalse($validator->isValid('feature/foo')); // * cannot cross / @@ -282,7 +282,7 @@ public function testStarAloneMatchesSingleSegmentOnly(): void public function testDoubleStarAloneMatchesEverything(): void { - $validator = new Glob(['**']); + $validator = new Globstar(['**']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('feature/foo')); $this->assertTrue($validator->isValid('src/a/b/c/d/file.php')); @@ -294,7 +294,7 @@ public function testDoubleStarAloneMatchesEverything(): void public function testStarDotExtMatchesRootLevelOnly(): void { - $validator = new Glob(['*.php']); + $validator = new Globstar(['*.php']); $this->assertTrue($validator->isValid('Foo.php')); $this->assertTrue($validator->isValid('index.php')); $this->assertFalse($validator->isValid('src/Foo.php')); // * does not cross / @@ -304,7 +304,7 @@ public function testStarDotExtMatchesRootLevelOnly(): void public function testDoubleStarSlashExtMatchesAnyDepth(): void { - $validator = new Glob(['**/*.php']); + $validator = new Globstar(['**/*.php']); $this->assertTrue($validator->isValid('Foo.php')); $this->assertTrue($validator->isValid('src/Foo.php')); $this->assertTrue($validator->isValid('src/components/Foo.php')); @@ -315,7 +315,7 @@ public function testDoubleStarSlashExtMatchesAnyDepth(): void public function testDirPrefixDoubleStarExtPattern(): void { - $validator = new Glob(['src/**/*.php']); + $validator = new Globstar(['src/**/*.php']); $this->assertTrue($validator->isValid('src/Foo.php')); $this->assertTrue($validator->isValid('src/components/Foo.php')); $this->assertTrue($validator->isValid('src/a/b/c/Foo.php')); @@ -327,55 +327,55 @@ public function testDirPrefixDoubleStarExtPattern(): void public function testDoubleWildcardAtStartAlternateFilename(): void { // **/temp.txt — different filename from existing **/file.txt tests - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('temp.txt')); - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/temp.txt')); - $this->assertTrue((new Glob(['**/temp.txt']))->isValid('a/b/temp.txt')); + $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('temp.txt')); + $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('a/temp.txt')); + $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('a/b/temp.txt')); } public function testDoubleWildcardAtEndNoExtension(): void { // src/** without extension filter - $this->assertTrue((new Glob(['src/**']))->isValid('src/file.txt')); - $this->assertTrue((new Glob(['src/**']))->isValid('src/a/file.txt')); + $this->assertTrue((new Globstar(['src/**']))->isValid('src/file.txt')); + $this->assertTrue((new Globstar(['src/**']))->isValid('src/a/file.txt')); } public function testDoubleWildcardLogExtension(): void { // src/**/*.log - $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/error.log')); - $this->assertTrue((new Glob(['src/**/*.log']))->isValid('src/a/debug.log')); + $this->assertTrue((new Globstar(['src/**/*.log']))->isValid('src/error.log')); + $this->assertTrue((new Globstar(['src/**/*.log']))->isValid('src/a/debug.log')); } public function testDoubleWildcardMiddleWithTwoSegmentTail(): void { // a/**/b/c — tail is two segments (b/c) - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/b/c')); // zero intermediate - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/b/c')); // one intermediate - $this->assertTrue((new Glob(['a/**/b/c']))->isValid('a/x/y/b/c')); // two intermediate - $this->assertFalse((new Glob(['a/**/b/c']))->isValid('a/b/d')); // wrong tail + $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/b/c')); // zero intermediate + $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/x/b/c')); // one intermediate + $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/x/y/b/c')); // two intermediate + $this->assertFalse((new Globstar(['a/**/b/c']))->isValid('a/b/d')); // wrong tail } public function testDoubleWildcardBothSides(): void { // **/d/e/** — globstar on both sides of a literal segment pair - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('d/e/file.txt')); - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/d/e/file.txt')); - $this->assertTrue((new Glob(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); - $this->assertFalse((new Glob(['**/d/e/**']))->isValid('d/f/file.txt')); + $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('d/e/file.txt')); + $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('x/d/e/file.txt')); + $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); + $this->assertFalse((new Globstar(['**/d/e/**']))->isValid('d/f/file.txt')); } public function testDoubleWildcardWithJsExtension(): void { // src/foo/**/*.js - $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); - $this->assertTrue((new Glob(['src/foo/**/*.js']))->isValid('src/foo/file.js')); - $this->assertFalse((new Glob(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); + $this->assertTrue((new Globstar(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); + $this->assertTrue((new Globstar(['src/foo/**/*.js']))->isValid('src/foo/file.js')); + $this->assertFalse((new Globstar(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); } public function testDoubleWildcardDeepNesting(): void { // Very deep path with ** in the middle - $this->assertTrue((new Glob(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); + $this->assertTrue((new Globstar(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); } // ------------------------------------------------------------------------- @@ -384,7 +384,7 @@ public function testDoubleWildcardDeepNesting(): void public function testDotsInPatternAreLiteral(): void { - $validator = new Glob(['release-1.0.0']); + $validator = new Globstar(['release-1.0.0']); $this->assertTrue($validator->isValid('release-1.0.0')); $this->assertFalse($validator->isValid('release-1X0Y0')); $this->assertFalse($validator->isValid('release-1.0.0-hotfix')); @@ -392,7 +392,7 @@ public function testDotsInPatternAreLiteral(): void public function testVersionWildcardBranchPattern(): void { - $validator = new Glob(['v*.*.*']); + $validator = new Globstar(['v*.*.*']); $this->assertTrue($validator->isValid('v1.2.3')); $this->assertTrue($validator->isValid('v10.20.30')); $this->assertTrue($validator->isValid('v1.2.3.4')); @@ -403,7 +403,7 @@ public function testVersionWildcardBranchPattern(): void public function testDottedFilenamePattern(): void { - $validator = new Glob(['*.test.js']); + $validator = new Globstar(['*.test.js']); $this->assertTrue($validator->isValid('Button.test.js')); $this->assertTrue($validator->isValid('App.test.js')); $this->assertFalse($validator->isValid('ButtonXtestYjs')); @@ -417,7 +417,7 @@ public function testDottedFilenamePattern(): void public function testPrefixWildcardBranchPattern(): void { - $validator = new Glob(['main*']); + $validator = new Globstar(['main*']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('main-extra')); $this->assertTrue($validator->isValid('mainline')); @@ -431,7 +431,7 @@ public function testPrefixWildcardBranchPattern(): void public function testDoubleWildcardInMiddleDeepNesting(): void { - $validator = new Glob(['a/**/b']); + $validator = new Globstar(['a/**/b']); $this->assertTrue($validator->isValid('a/x/y/z/b')); $this->assertTrue($validator->isValid('a/p/q/r/s/b')); $this->assertTrue($validator->isValid('a/1/2/3/4/5/b')); @@ -441,7 +441,7 @@ public function testDoubleWildcardInMiddleDeepNesting(): void public function testDoubleWildcardAtStartDeepNesting(): void { - $validator = new Glob(['**/README.md']); + $validator = new Globstar(['**/README.md']); $this->assertTrue($validator->isValid('README.md')); $this->assertTrue($validator->isValid('docs/README.md')); $this->assertTrue($validator->isValid('a/b/c/d/README.md')); @@ -456,7 +456,7 @@ public function testDoubleWildcardAtStartDeepNesting(): void public function testGeneratedFilesAnywhereExclusion(): void { - $validator = new Glob(['!**/generated/**']); + $validator = new Globstar(['!**/generated/**']); $this->assertFalse($validator->isValid('generated/Foo.php')); $this->assertFalse($validator->isValid('src/generated/Foo.php')); $this->assertFalse($validator->isValid('src/api/generated/Bar.php')); @@ -467,7 +467,7 @@ public function testGeneratedFilesAnywhereExclusion(): void public function testMultipleExtensionInclusions(): void { - $validator = new Glob(['**/*.php', '**/*.js']); + $validator = new Globstar(['**/*.php', '**/*.js']); $this->assertTrue($validator->isValid('index.php')); $this->assertTrue($validator->isValid('src/App.php')); $this->assertTrue($validator->isValid('index.js')); @@ -483,7 +483,7 @@ public function testMultipleExtensionInclusions(): void public function testReleaseBranchPattern(): void { - $validator = new Glob(['release/*']); + $validator = new Globstar(['release/*']); $this->assertTrue($validator->isValid('release/1.0')); $this->assertTrue($validator->isValid('release/hotfix')); $this->assertTrue($validator->isValid('release/2024-01-15')); @@ -498,12 +498,12 @@ public function testReleaseBranchPattern(): void public function testPatternMatchingIsCaseSensitive(): void { - $branchValidator = new Glob(['main']); + $branchValidator = new Globstar(['main']); $this->assertTrue($branchValidator->isValid('main')); $this->assertFalse($branchValidator->isValid('Main')); $this->assertFalse($branchValidator->isValid('MAIN')); - $wildcardValidator = new Glob(['feature/*']); + $wildcardValidator = new Globstar(['feature/*']); $this->assertTrue($wildcardValidator->isValid('feature/foo')); $this->assertFalse($wildcardValidator->isValid('Feature/foo')); $this->assertFalse($wildcardValidator->isValid('FEATURE/foo')); @@ -515,7 +515,7 @@ public function testPatternMatchingIsCaseSensitive(): void public function testCharacterClassInclusion(): void { - $validator = new Glob(['[Mm]ain']); + $validator = new Globstar(['[Mm]ain']); $this->assertTrue($validator->isValid('main')); $this->assertTrue($validator->isValid('Main')); $this->assertFalse($validator->isValid('MAIN')); @@ -526,14 +526,14 @@ public function testCharacterClassInclusionWithWildcardExclusion(): void { // [Mm]ain is a character-class pattern (not a literal), so it must not // short-circuit before exclusions are evaluated. - $validator = new Glob(['[Mm]ain', '!**']); + $validator = new Globstar(['[Mm]ain', '!**']); $this->assertFalse($validator->isValid('main')); $this->assertFalse($validator->isValid('Main')); } public function testCharacterClassExclusion(): void { - $validator = new Glob(['!feature/[0-9]*']); + $validator = new Globstar(['!feature/[0-9]*']); $this->assertFalse($validator->isValid('feature/123')); $this->assertFalse($validator->isValid('feature/9fix')); $this->assertTrue($validator->isValid('feature/abc')); @@ -546,7 +546,7 @@ public function testCharacterClassExclusion(): void public function testValidatorMetadata(): void { - $validator = new Glob([]); + $validator = new Globstar([]); $this->assertFalse($validator->isArray()); $this->assertSame(\Utopia\Validator::TYPE_STRING, $validator->getType()); $this->assertNotEmpty($validator->getDescription()); @@ -554,7 +554,7 @@ public function testValidatorMetadata(): void public function testRejectsNonStringValues(): void { - $validator = new Glob(['main']); + $validator = new Globstar(['main']); $this->assertFalse($validator->isValid(123)); $this->assertFalse($validator->isValid(null)); $this->assertFalse($validator->isValid(['main'])); @@ -567,141 +567,141 @@ public function testRejectsNonStringValues(): void public function testLiteralsExact(): void { - $this->assertTrue((new Glob(['file.txt']))->isValid('file.txt')); - $this->assertFalse((new Glob(['file.txt']))->isValid('file.txt.bak')); + $this->assertTrue((new Globstar(['file.txt']))->isValid('file.txt')); + $this->assertFalse((new Globstar(['file.txt']))->isValid('file.txt.bak')); } public function testSingleAsteriskDoesNotCrossSlash(): void { - $this->assertTrue((new Glob(['*.txt']))->isValid('file.txt')); - $this->assertTrue((new Glob(['*.txt']))->isValid('another.txt')); - $this->assertFalse((new Glob(['*.txt']))->isValid('file.txt.bak')); - $this->assertFalse((new Glob(['*.txt']))->isValid('dir/file.txt')); // * does not cross / + $this->assertTrue((new Globstar(['*.txt']))->isValid('file.txt')); + $this->assertTrue((new Globstar(['*.txt']))->isValid('another.txt')); + $this->assertFalse((new Globstar(['*.txt']))->isValid('file.txt.bak')); + $this->assertFalse((new Globstar(['*.txt']))->isValid('dir/file.txt')); // * does not cross / } public function testQuestionMarkSingleChar(): void { - $this->assertTrue((new Glob(['file.?xt']))->isValid('file.txt')); - $this->assertTrue((new Glob(['file.?xt']))->isValid('file.dxt')); - $this->assertFalse((new Glob(['file.?xt']))->isValid('file.xtt')); + $this->assertTrue((new Globstar(['file.?xt']))->isValid('file.txt')); + $this->assertTrue((new Globstar(['file.?xt']))->isValid('file.dxt')); + $this->assertFalse((new Globstar(['file.?xt']))->isValid('file.xtt')); } public function testDoubleStarPrefixFileMatch(): void { - $this->assertTrue((new Glob(['**/file.txt']))->isValid('file.txt')); - $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/file.txt')); - $this->assertTrue((new Glob(['**/file.txt']))->isValid('dir/subdir/file.txt')); - $this->assertFalse((new Glob(['**/file.txt']))->isValid('file.txt.bak')); + $this->assertTrue((new Globstar(['**/file.txt']))->isValid('file.txt')); + $this->assertTrue((new Globstar(['**/file.txt']))->isValid('dir/file.txt')); + $this->assertTrue((new Globstar(['**/file.txt']))->isValid('dir/subdir/file.txt')); + $this->assertFalse((new Globstar(['**/file.txt']))->isValid('file.txt.bak')); } public function testDoubleStarMiddleFileMatch(): void { - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/file.txt')); - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/file.txt')); - $this->assertTrue((new Glob(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); - $this->assertFalse((new Glob(['src/**/file.txt']))->isValid('other/file.txt')); + $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/file.txt')); + $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/dir/file.txt')); + $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); + $this->assertFalse((new Globstar(['src/**/file.txt']))->isValid('other/file.txt')); } public function testEscapedAsteriskIsLiteral(): void { - $this->assertTrue((new Glob(['file\*.txt']))->isValid('file*.txt')); - $this->assertFalse((new Glob(['file\*.txt']))->isValid('fileX.txt')); + $this->assertTrue((new Globstar(['file\*.txt']))->isValid('file*.txt')); + $this->assertFalse((new Globstar(['file\*.txt']))->isValid('fileX.txt')); } public function testEscapedHashIsNotComment(): void { // \# must be treated as a literal # character, not a comment marker - $this->assertTrue((new Glob(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); + $this->assertTrue((new Globstar(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); } public function testEscapedQuestionMarkIsLiteral(): void { // \? must match literal ? rather than any single character - $this->assertTrue((new Glob(['file\?.txt']))->isValid('file?.txt')); + $this->assertTrue((new Globstar(['file\?.txt']))->isValid('file?.txt')); } public function testBasicCharacterClasses(): void { - $this->assertTrue((new Glob(['[a]bc.txt']))->isValid('abc.txt')); - $this->assertFalse((new Glob(['[a]bc.txt']))->isValid('bbc.txt')); - $this->assertTrue((new Glob(['[a-z]est.txt']))->isValid('test.txt')); - $this->assertFalse((new Glob(['[a-z]est.txt']))->isValid('Test.txt')); - $this->assertTrue((new Glob(['[A-Z]est.txt']))->isValid('Test.txt')); - $this->assertFalse((new Glob(['[A-Z]est.txt']))->isValid('test.txt')); - $this->assertTrue((new Glob(['file[0-9].log']))->isValid('file5.log')); - $this->assertFalse((new Glob(['file[0-9].log']))->isValid('fileA.log')); - $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Glob(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); - $this->assertFalse((new Glob(['[a-zA-Z]file.txt']))->isValid('1file.txt')); + $this->assertTrue((new Globstar(['[a]bc.txt']))->isValid('abc.txt')); + $this->assertFalse((new Globstar(['[a]bc.txt']))->isValid('bbc.txt')); + $this->assertTrue((new Globstar(['[a-z]est.txt']))->isValid('test.txt')); + $this->assertFalse((new Globstar(['[a-z]est.txt']))->isValid('Test.txt')); + $this->assertTrue((new Globstar(['[A-Z]est.txt']))->isValid('Test.txt')); + $this->assertFalse((new Globstar(['[A-Z]est.txt']))->isValid('test.txt')); + $this->assertTrue((new Globstar(['file[0-9].log']))->isValid('file5.log')); + $this->assertFalse((new Globstar(['file[0-9].log']))->isValid('fileA.log')); + $this->assertTrue((new Globstar(['[a-zA-Z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Globstar(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); + $this->assertFalse((new Globstar(['[a-zA-Z]file.txt']))->isValid('1file.txt')); } public function testNegatedCharacterClasses(): void { - $this->assertTrue((new Glob(['[!a-z]file.txt']))->isValid('Afile.txt')); - $this->assertFalse((new Glob(['[!a-z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Glob(['^[^a-z]file.txt']))->isValid('^1file.txt')); - $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Glob(['[!a-z0-9]file.txt']))->isValid('#file.txt')); - $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('afile.txt')); - $this->assertFalse((new Glob(['[!a-z0-9]file.txt']))->isValid('5file.txt')); + $this->assertTrue((new Globstar(['[!a-z]file.txt']))->isValid('Afile.txt')); + $this->assertFalse((new Globstar(['[!a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Globstar(['^[^a-z]file.txt']))->isValid('^1file.txt')); + $this->assertFalse((new Globstar(['[^a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Globstar(['[!a-z0-9]file.txt']))->isValid('#file.txt')); + $this->assertFalse((new Globstar(['[!a-z0-9]file.txt']))->isValid('afile.txt')); + $this->assertFalse((new Globstar(['[!a-z0-9]file.txt']))->isValid('5file.txt')); } public function testCaretNegatedCharacterClass(): void { - $this->assertTrue((new Glob(['[^a-z]file.txt']))->isValid('1file.txt')); - $this->assertFalse((new Glob(['[^a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue((new Globstar(['[^a-z]file.txt']))->isValid('1file.txt')); + $this->assertFalse((new Globstar(['[^a-z]file.txt']))->isValid('afile.txt')); } public function testSpecialCharsInsideCharacterClasses(): void { - $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file.name.txt')); - $this->assertTrue((new Glob(['file[.+]name.txt']))->isValid('file+name.txt')); - $this->assertFalse((new Glob(['file[.+]name.txt']))->isValid('filename.txt')); - $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('_special.txt')); - $this->assertTrue((new Glob(['[_!@#]special.txt']))->isValid('@special.txt')); - $this->assertFalse((new Glob(['[_!@#]special.txt']))->isValid('xspecial.txt')); - $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('-dash.txt')); - $this->assertTrue((new Glob(['[-abc]dash.txt']))->isValid('adash.txt')); - $this->assertTrue((new Glob(['[abc-]dash.txt']))->isValid('-dash.txt')); + $this->assertTrue((new Globstar(['file[.+]name.txt']))->isValid('file.name.txt')); + $this->assertTrue((new Globstar(['file[.+]name.txt']))->isValid('file+name.txt')); + $this->assertFalse((new Globstar(['file[.+]name.txt']))->isValid('filename.txt')); + $this->assertTrue((new Globstar(['[_!@#]special.txt']))->isValid('_special.txt')); + $this->assertTrue((new Globstar(['[_!@#]special.txt']))->isValid('@special.txt')); + $this->assertFalse((new Globstar(['[_!@#]special.txt']))->isValid('xspecial.txt')); + $this->assertTrue((new Globstar(['[-abc]dash.txt']))->isValid('-dash.txt')); + $this->assertTrue((new Globstar(['[-abc]dash.txt']))->isValid('adash.txt')); + $this->assertTrue((new Globstar(['[abc-]dash.txt']))->isValid('-dash.txt')); } public function testCharacterClassCombinedWithGlobstar(): void { - $this->assertTrue((new Glob(['[a-z]*.txt']))->isValid('abc.txt')); - $this->assertFalse((new Glob(['[a-z]*.txt']))->isValid('Abc.txt')); - $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); - $this->assertTrue((new Glob(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); - $this->assertFalse((new Glob(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); - $this->assertTrue((new Glob(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); - $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('ab.txt')); - $this->assertFalse((new Glob(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); + $this->assertTrue((new Globstar(['[a-z]*.txt']))->isValid('abc.txt')); + $this->assertFalse((new Globstar(['[a-z]*.txt']))->isValid('Abc.txt')); + $this->assertTrue((new Globstar(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); + $this->assertTrue((new Globstar(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); + $this->assertFalse((new Globstar(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); + $this->assertTrue((new Globstar(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); + $this->assertFalse((new Globstar(['[a-z][0-9]*.txt']))->isValid('ab.txt')); + $this->assertFalse((new Globstar(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); } public function testEdgeCaseEmptyCharacterClass(): void { // Empty character class [] matches nothing - $this->assertFalse((new Glob(['[]file.txt']))->isValid('file.txt')); + $this->assertFalse((new Globstar(['[]file.txt']))->isValid('file.txt')); } public function testEdgeCaseUnclosedBracket(): void { // Unclosed bracket treated as literal - $this->assertTrue((new Glob(['[abc']))->isValid('[abc')); - $this->assertFalse((new Glob(['[abc']))->isValid('abc')); + $this->assertTrue((new Globstar(['[abc']))->isValid('[abc')); + $this->assertFalse((new Globstar(['[abc']))->isValid('abc')); } public function testEdgeCaseExclamationOnlyClass(): void { // [!] — exclamation as sole content; behaviour is implementation-defined // but the implementation treats it as a literal class containing '!' - $this->assertTrue((new Glob(['[!]file.txt']))->isValid('!file.txt')); + $this->assertTrue((new Globstar(['[!]file.txt']))->isValid('!file.txt')); } public function testEdgeCaseCaretOnlyClass(): void { // [^] — caret as sole content; behaviour is implementation-defined // but the implementation treats it as a literal class containing '^' - $this->assertTrue((new Glob(['[^]file.txt']))->isValid('^file.txt')); + $this->assertTrue((new Globstar(['[^]file.txt']))->isValid('^file.txt')); } // ------------------------------------------------------------------------- @@ -716,10 +716,10 @@ public function testComplexNegationChainPattern(): void // - README-private*.md is re-included by the third pattern $patterns = ['*.md', '!README*.md', 'README-private*.md']; - $this->assertTrue((new Glob($patterns))->isValid('documentation.md')); // included, not excluded - $this->assertFalse((new Glob($patterns))->isValid('README.md')); // excluded by !README*.md - $this->assertFalse((new Glob($patterns))->isValid('README-public.md')); // excluded by !README*.md - $this->assertTrue((new Glob($patterns))->isValid('README-private.md')); // re-included by README-private*.md - $this->assertTrue((new Glob($patterns))->isValid('README-private-draft.md')); // re-included by README-private*.md + $this->assertTrue((new Globstar($patterns))->isValid('documentation.md')); // included, not excluded + $this->assertFalse((new Globstar($patterns))->isValid('README.md')); // excluded by !README*.md + $this->assertFalse((new Globstar($patterns))->isValid('README-public.md')); // excluded by !README*.md + $this->assertTrue((new Globstar($patterns))->isValid('README-private.md')); // re-included by README-private*.md + $this->assertTrue((new Globstar($patterns))->isValid('README-private-draft.md')); // re-included by README-private*.md } }