From b8256cecae96eb37d8275399928c39d7689d3105 Mon Sep 17 00:00:00 2001 From: Jaapio Date: Fri, 20 Dec 2019 17:43:34 +0100 Subject: [PATCH] implement glob spec --- .../04-sample-phpdoc-layout-using-glob.php | 35 +++ phpcs.xml.dist | 1 + src/Specification/Glob.php | 261 ++++++++++++++++++ ...t.php => FindOnSamplePhpdocLayoutTest.php} | 2 +- .../FindOnSamplePhpdocLayoutUsingGlobTest.php | 36 +++ tests/unit/Specification/GlobTest.php | 169 ++++++++++++ 6 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 examples/04-sample-phpdoc-layout-using-glob.php create mode 100644 src/Specification/Glob.php rename tests/integration/{FindOnSamplePhpdocLayout.php => FindOnSamplePhpdocLayoutTest.php} (94%) create mode 100644 tests/integration/FindOnSamplePhpdocLayoutUsingGlobTest.php create mode 100644 tests/unit/Specification/GlobTest.php diff --git a/examples/04-sample-phpdoc-layout-using-glob.php b/examples/04-sample-phpdoc-layout-using-glob.php new file mode 100644 index 0000000..99c39b4 --- /dev/null +++ b/examples/04-sample-phpdoc-layout-using-glob.php @@ -0,0 +1,35 @@ +addPlugin(new Finder()); + +/* + * "phpdoc -d src -i src/phpDocumentor/DomainModel" + * should result in src/Cilex and src/phpDocumentor/. files being found, + * but src/phpDocumentor/DomainModel files being left out + */ +$dashDirectoryPath = new Glob('/src/**/*'); +$dashIgnorePath = new InPath(new Path('src/phpDocumentor/DomainModel')); +$isHidden = new IsHidden(); +$isPhpFile = new HasExtension(['php']); +$spec = new AndSpecification($dashDirectoryPath, $dashIgnorePath->notSpecification()); +$spec->andSpecification($isHidden->notSpecification()); +$spec->andSpecification($isPhpFile); + +$generator = $filesystem->find($spec); +$result = []; +foreach($generator as $value) { + $result[] = $value; +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index ad2d53d..d9873c7 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -17,6 +17,7 @@ */src/Finder.php */src/Specification/SpecificationInterface.php + */tests/unit/Specification/GlobTest.php diff --git a/src/Specification/Glob.php b/src/Specification/Glob.php new file mode 100644 index 0000000..e07977a --- /dev/null +++ b/src/Specification/Glob.php @@ -0,0 +1,261 @@ +regex = self::toRegEx($glob); + $this->staticPrefix = self::getStaticPrefix($glob); + } + + /** + * @inheritDoc + */ + public function isSatisfiedBy(array $value) : bool + { + //Flysystem paths are not absolute, so make it that way. + $path = '/' . $value['path']; + if (strpos($path, $this->staticPrefix) !== 0) { + return false; + } + + if (preg_match($this->regex, $path)) { + return true; + } + + return false; + } + + /** + * Returns the static prefix of a glob. + * + * The "static prefix" is the part of the glob up to the first wildcard "*". + * If the glob does not contain wildcards, the full glob is returned. + * + * @param string $glob The canonical glob. The glob should contain forward + * slashes as directory separators only. It must not + * contain any "." or ".." segments. + * + * @return string The static prefix of the glob. + * + * @psalm-pure + */ + private static function getStaticPrefix(string $glob) : string + { + if (strpos($glob, '/') !== 0 && strpos($glob, '://') === false) { + throw new InvalidArgumentException(sprintf( + 'The glob "%s" is not absolute and not a URI.', + $glob + )); + } + $prefix = ''; + $length = strlen($glob); + for ($i = 0; $i < $length; ++$i) { + $c = $glob[$i]; + switch ($c) { + case '/': + $prefix .= '/'; + if (self::isRecursiveWildcard($glob, $i)) { + break 2; + } + break; + case '*': + case '?': + case '{': + case '[': + break 2; + case '\\': + if (isset($glob[$i + 1])) { + switch ($glob[$i + 1]) { + case '*': + case '?': + case '{': + case '}': + case '[': + case ']': + case '-': + case '^': + case '$': + case '~': + case '\\': + $prefix .= $glob[$i + 1]; + ++$i; + break; + default: + $prefix .= '\\'; + } + } + break; + default: + $prefix .= $c; + break; + } + } + return $prefix; + } + + /** + * Checks if the current position the glob is start of a Recursive directory wildcard + * + * @psalm-pure + */ + private static function isRecursiveWildcard(string $glob, int $i) : bool + { + return isset($glob[$i + 3]) && $glob[$i + 1] . $glob[$i + 2] . $glob[$i + 3] === '**/'; + } + + /** + * Converts a glob to a regular expression. + * + * @param string $glob The canonical glob. The glob should contain forward + * slashes as directory separators only. It must not + * contain any "." or ".." segments. + * + * @return string The regular expression for matching the glob. + * + * @psalm-pure + */ + private static function toRegEx(string $glob) : string + { + $delimiter = '~'; + $inSquare = false; + $curlyLevels = 0; + $regex = ''; + $length = strlen($glob); + for ($i = 0; $i < $length; ++$i) { + $c = $glob[$i]; + switch ($c) { + case '.': + case '(': + case ')': + case '|': + case '+': + case '^': + case '$': + case $delimiter: + $regex .= '\\' . $c; + break; + case '/': + if (self::isRecursiveWildcard($glob, $i)) { + $regex .= '/([^/]+/)*'; + $i += 3; + } else { + $regex .= '/'; + } + break; + case '*': + $regex .= '[^/]*'; + break; + case '?': + $regex .= '.'; + break; + case '{': + $regex .= '('; + ++$curlyLevels; + break; + case '}': + if ($curlyLevels > 0) { + $regex .= ')'; + --$curlyLevels; + } else { + $regex .= '}'; + } + break; + case ',': + $regex .= $curlyLevels > 0 ? '|' : ','; + break; + case '[': + $regex .= '['; + $inSquare = true; + if (isset($glob[$i + 1]) && $glob[$i + 1] === '^') { + $regex .= '^'; + ++$i; + } + break; + case ']': + $regex .= $inSquare ? ']' : '\\]'; + $inSquare = false; + break; + case '-': + $regex .= $inSquare ? '-' : '\\-'; + break; + case '\\': + if (isset($glob[$i + 1])) { + switch ($glob[$i + 1]) { + case '*': + case '?': + case '{': + case '}': + case '[': + case ']': + case '-': + case '^': + case '$': + case '~': + case '\\': + $regex .= '\\' . $glob[$i + 1]; + ++$i; + break; + default: + $regex .= '\\\\'; + } + } + break; + default: + $regex .= $c; + break; + } + } + if ($inSquare) { + throw new InvalidArgumentException(sprintf( + 'Invalid glob: missing ] in %s', + $glob + )); + } + if ($curlyLevels > 0) { + throw new InvalidArgumentException(sprintf( + 'Invalid glob: missing } in %s', + $glob + )); + } + return $delimiter . '^' . $regex . '$' . $delimiter; + } +} diff --git a/tests/integration/FindOnSamplePhpdocLayout.php b/tests/integration/FindOnSamplePhpdocLayoutTest.php similarity index 94% rename from tests/integration/FindOnSamplePhpdocLayout.php rename to tests/integration/FindOnSamplePhpdocLayoutTest.php index 56045e7..3d263d5 100644 --- a/tests/integration/FindOnSamplePhpdocLayout.php +++ b/tests/integration/FindOnSamplePhpdocLayoutTest.php @@ -20,7 +20,7 @@ * * @coversNothing */ -class FindOnSamplePhpdocLayout extends TestCase +class FindOnSamplePhpdocLayoutTest extends TestCase { public function testFindingOnSamplePhpdocLayout() : void { diff --git a/tests/integration/FindOnSamplePhpdocLayoutUsingGlobTest.php b/tests/integration/FindOnSamplePhpdocLayoutUsingGlobTest.php new file mode 100644 index 0000000..1c67287 --- /dev/null +++ b/tests/integration/FindOnSamplePhpdocLayoutUsingGlobTest.php @@ -0,0 +1,36 @@ +assertCount(4, $result); + $this->assertSame('JmsSerializerServiceProvider.php', $result[0]['basename']); + $this->assertSame('MonologServiceProvider.php', $result[1]['basename']); + $this->assertSame('Application.php', $result[2]['basename']); + $this->assertSame('Bootstrap.php', $result[3]['basename']); + } +} diff --git a/tests/unit/Specification/GlobTest.php b/tests/unit/Specification/GlobTest.php new file mode 100644 index 0000000..f115562 --- /dev/null +++ b/tests/unit/Specification/GlobTest.php @@ -0,0 +1,169 @@ + + * @covers ::isSatisfiedBy + * @covers ::__construct + */ +final class GlobTest extends TestCase +{ + /** + * @param mixed[] $file + * + * @dataProvider matchingPatternFileProvider + * @dataProvider matchingPatternFileWithEscapeCharProvider + * + * @psalm-param array{basename: string, path: string, stream: resource, dirname: string, type: string, extension: string} $file + */ + public function testGlobIsMatching(string $pattern, array $file) : void + { + $glob = new Glob($pattern); + + $this->assertTrue( + $glob->isSatisfiedBy($file), + sprintf('Failed: %s to match %s', $pattern, $file['path']) + ); + } + + /** + * @param mixed[] $file + * + * @dataProvider notMatchingPatternFileProvider + * @psalm-param array{basename: string, path: string, stream: resource, dirname: string, type: string, extension: string} $file + */ + public function testGlobIsNotMatching(string $pattern, array $file) : void + { + $glob = new Glob($pattern); + + $this->assertFalse( + $glob->isSatisfiedBy($file), + sprintf('Failed: %s to match %s', $pattern, $file['path']) + ); + } + + /** + * @dataProvider invalidPatternProvider + */ + public function testInvalidGlobThrows(string $pattern) : void + { + $this->expectException(InvalidArgumentException::class); + new Glob($pattern); + } + + public function invalidPatternProvider() : Generator + { + $invalidPatterns = [ + '[aaa', + '{aaa', + '{a,{b}', + 'aaaa', //path must be absolute + ]; + + foreach ($invalidPatterns as $pattern) { + yield $pattern => [$pattern]; + } + } + + public function matchingPatternFileProvider() : Generator + { + $input = [ + '/*.php' => 'test.php', + '/src/*' => 'src/test.php', + '/src/**/*.php' => 'src/subdir/test.php', + '/src/**/*' => 'src/subdir/second/test.php', + '/src/{subdir,other}/*' => [ + 'src/subdir/test.php', + 'src/other/test.php', + ], + '/src/subdir/test-[a-c].php' => [ + 'src/subdir/test-a.php', + 'src/subdir/test-b.php', + 'src/subdir/test-c.php', + ], + '/src/subdir/test-[^a-c].php' => 'src/subdir/test-d.php', + '/src/subdir/test-?.php' => [ + 'src/subdir/test-a.php', + 'src/subdir/test-b.php', + 'src/subdir/test-c.php', + 'src/subdir/test-~.php', + ], + '/src/subdir/test-}.php' => 'src/subdir/test-}.php', + ]; + + yield from $this->toTestData($input); + } + + public function matchingPatternFileWithEscapeCharProvider() : Generator + { + $escapeChars = [ + '*', + '?', + '{', + '}', + '[', + ']', + '-', + '^', + '$', + '~', + '\\', + '\\\\', + ]; + + foreach ($escapeChars as $char) { + $file = sprintf('/src/test\\%s.php', $char); + yield $file => [ + $file, + ['path' => sprintf('src/test%s.php', $char)], + ]; + } + } + + public function notMatchingPatternFileProvider() : Generator + { + $input = [ + '/*.php' => 'test.css', + '/src/*' => 'src/subdir/test.php', + '/src/**/*.php' => 'src/subdir/test.css', + '/src/subdir/test-[a-c].php' => 'src/subdir/test-d.php', + '/src/subdir/test-[^a-c].php' => [ + 'src/subdir/test-a.php', + 'src/subdir/test-b.php', + 'src/subdir/test-c.php', + ], + '/src' => 'test/file.php', + ]; + + yield from $this->toTestData($input); + } + + /** + * @param mixed[] $input + */ + private function toTestData(array $input) : Generator + { + foreach ($input as $glob => $path) { + if (!is_array($path)) { + $path = [$path]; + } + + foreach ($path as $key => $item) { + yield ($key !== 0 ? $key . ' - ' : '') . $glob => [ + $glob, + ['path' => $item], + ]; + } + } + } +}