Skip to content

Commit

Permalink
Merge pull request #15 from rulatir/irrelevant-path-culling-optimization
Browse files Browse the repository at this point in the history
Path pruning
  • Loading branch information
mvriel committed Jan 17, 2020
2 parents 5163d92 + ac28b19 commit 817b847
Show file tree
Hide file tree
Showing 11 changed files with 619 additions and 33 deletions.
5 changes: 4 additions & 1 deletion composer.json
Expand Up @@ -11,7 +11,10 @@
},
"autoload-dev": {
"psr-4": {
"Flyfinder\\": ["tests/unit/"]
"Flyfinder\\": [
"tests/integration/",
"tests/unit/"
]
}
},
"require": {
Expand Down
3 changes: 3 additions & 0 deletions phpcs.xml.dist
Expand Up @@ -17,12 +17,15 @@
<rule ref="Generic.Files.LineLength.TooLong">
<exclude-pattern>*/src/Finder.php</exclude-pattern>
<exclude-pattern>*/src/Specification/SpecificationInterface.php</exclude-pattern>
<exclude-pattern>*/src/Specification/PrunableInterface.php</exclude-pattern>
<exclude-pattern>*/src/Specification/CompositeSpecification.php</exclude-pattern>
<exclude-pattern>*/tests/unit/Specification/GlobTest.php</exclude-pattern>
</rule>


<rule ref="SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming.SuperfluousSuffix">
<exclude-pattern>*/src/Specification/SpecificationInterface.php</exclude-pattern>
<exclude-pattern>*/src/Specification/PrunableInterface.php</exclude-pattern>
</rule>

<rule ref="Generic.Formatting.SpaceAfterNot">
Expand Down
9 changes: 5 additions & 4 deletions src/Finder.php
Expand Up @@ -13,6 +13,7 @@

namespace Flyfinder;

use Flyfinder\Specification\CompositeSpecification;
use Flyfinder\Specification\SpecificationInterface;
use Generator;
use League\Flysystem\File;
Expand Down Expand Up @@ -88,13 +89,13 @@ private function yieldFilesInPath(SpecificationInterface $specification, string
yield $location;
}

if ($location['type'] !== 'dir') {
if ($location['type'] !== 'dir'
|| !CompositeSpecification::thatCanBeSatisfiedBySomethingBelow($specification, $location)
) {
continue;
}

foreach ($this->yieldFilesInPath($specification, $location['path']) as $returnedLocation) {
yield $returnedLocation;
}
yield from $this->yieldFilesInPath($specification, $location['path']);
}
}
}
14 changes: 14 additions & 0 deletions src/Specification/AndSpecification.php
Expand Up @@ -42,4 +42,18 @@ public function isSatisfiedBy(array $value) : bool
{
return $this->one->isSatisfiedBy($value) && $this->other->isSatisfiedBy($value);
}

/** {@inheritDoc} */
public function canBeSatisfiedBySomethingBelow(array $value) : bool
{
return self::thatCanBeSatisfiedBySomethingBelow($this->one, $value)
&& self::thatCanBeSatisfiedBySomethingBelow($this->other, $value);
}

/** {@inheritDoc} */
public function willBeSatisfiedByEverythingBelow(array $value) : bool
{
return self::thatWillBeSatisfiedByEverythingBelow($this->one, $value)
&& self::thatWillBeSatisfiedByEverythingBelow($this->other, $value);
}
}
45 changes: 44 additions & 1 deletion src/Specification/CompositeSpecification.php
Expand Up @@ -19,7 +19,7 @@
*
* @psalm-immutable
*/
abstract class CompositeSpecification implements SpecificationInterface
abstract class CompositeSpecification implements SpecificationInterface, PrunableInterface
{
/**
* Returns a specification that satisfies the original specification
Expand Down Expand Up @@ -47,4 +47,47 @@ public function notSpecification() : NotSpecification
{
return new NotSpecification($this);
}

/** {@inheritDoc} */
public function canBeSatisfiedBySomethingBelow(array $value) : bool
{
return true;
}

/** {@inheritDoc} */
public function willBeSatisfiedByEverythingBelow(array $value) : bool
{
return false;
}

/**
* Provide default {@see canBeSatisfiedBySomethingBelow()} logic for specification classes
* that don't implement PrunableInterface
*
* @param mixed[] $value
*
* @psalm-param array{basename: string, path: string, stream: resource, dirname: string, type: string, extension: string} $value
* @psalm-mutation-free
*/
public static function thatCanBeSatisfiedBySomethingBelow(SpecificationInterface $that, array $value) : bool
{
return $that instanceof PrunableInterface
? $that->canBeSatisfiedBySomethingBelow($value)
: true;
}

/**
* Provide default {@see willBeSatisfiedByEverythingBelow()} logic for specification classes
* that don't implement PrunableInterface
*
* @param mixed[] $value
*
* @psalm-param array{basename: string, path: string, stream: resource, dirname: string, type: string, extension: string} $value
* @psalm-mutation-free
*/
public static function thatWillBeSatisfiedByEverythingBelow(SpecificationInterface $that, array $value) : bool
{
return $that instanceof PrunableInterface
&& $that->willBeSatisfiedByEverythingBelow($value);
}
}
168 changes: 141 additions & 27 deletions src/Specification/Glob.php
Expand Up @@ -17,10 +17,18 @@
namespace Flyfinder\Specification;

use InvalidArgumentException;
use function array_slice;
use function count;
use function explode;
use function implode;
use function max;
use function min;
use function preg_match;
use function rtrim;
use function sprintf;
use function strlen;
use function strpos;
use function substr;

/**
* Glob specification class
Expand All @@ -40,10 +48,29 @@ final class Glob extends CompositeSpecification
*/
private $staticPrefix;

/**
* The "bounded prefix" is the part of the glob up to the first recursive wildcard "**".
* It is the longest prefix for which the number of directory segments in the partial match
* is known. If the glob does not contain the recursive wildcard "**", the full glob is returned.
*
* @var string
*/
private $boundedPrefix;

/**
* The "total prefix" is the part of the glob before the trailing catch-all wildcard sequence if the glob
* ends with one, otherwise null. It is needed for implementing the A-quantifier pruning hint.
*
* @var string|null
*/
private $totalPrefix;

public function __construct(string $glob)
{
$this->regex = self::toRegEx($glob);
$this->staticPrefix = self::getStaticPrefix($glob);
$this->regex = self::toRegEx($glob);
$this->staticPrefix = self::getStaticPrefix($glob);
$this->boundedPrefix = self::getBoundedPrefix($glob);
$this->totalPrefix = self::getTotalPrefix($glob);
}

/**
Expand Down Expand Up @@ -80,12 +107,7 @@ public function isSatisfiedBy(array $value) : bool
*/
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
));
}
self::assertValidGlob($glob);
$prefix = '';
$length = strlen($glob);
for ($i = 0; $i < $length; ++$i) {
Expand All @@ -103,27 +125,38 @@ private static function getStaticPrefix(string $glob) : string
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 .= '\\';
}
[$unescaped, $consumedChars] = self::scanBackslashSequence($glob, $i);
$prefix .= $unescaped;
$i += $consumedChars;
break;
default:
$prefix .= $c;
break;
}
}
return $prefix;
}

private static function getBoundedPrefix(string $glob) : string
{
self::assertValidGlob($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 '\\':
[$unescaped, $consumedChars] = self::scanBackslashSequence($glob, $i);
$prefix .= $unescaped;
$i += $consumedChars;
break;
default:
$prefix .= $c;
break;
Expand All @@ -132,6 +165,61 @@ private static function getStaticPrefix(string $glob) : string
return $prefix;
}

private static function getTotalPrefix(string $glob) : ?string
{
self::assertValidGlob($glob);
$matches = [];
return preg_match('~(?<!\\\\)/\\*\\*(?:/\\*\\*?)+$~', $glob, $matches)
? substr($glob, 0, strlen($glob)-strlen($matches[0]))
: null;
}

/**
* @return mixed[]
*
* @psalm-return array{0: string, 1:int}
* @psalm-pure
*/
private static function scanBackslashSequence(string $glob, int $offset) : array
{
$startOffset = $offset;
$result = '';
switch ($c = $glob[$offset + 1] ?? '') {
case '*':
case '?':
case '{':
case '}':
case '[':
case ']':
case '-':
case '^':
case '$':
case '~':
case '\\':
$result .= $c;
++$offset;
break;
default:
$result .= '\\';
}
return [$result, $offset - $startOffset];
}

/**
* Asserts that glob is well formed
*
* @psalm-pure
*/
private static function assertValidGlob(string $glob) : void
{
if (strpos($glob, '/') !== 0 && strpos($glob, '://') === false) {
throw new InvalidArgumentException(sprintf(
'The glob "%s" is not absolute and not a URI.',
$glob
));
}
}

/**
* Checks if the current position the glob is start of a Recursive directory wildcard
*
Expand Down Expand Up @@ -258,4 +346,30 @@ private static function toRegEx(string $glob) : string
}
return $delimiter . '^' . $regex . '$' . $delimiter;
}

/** @inheritDoc */
public function canBeSatisfiedBySomethingBelow(array $value) : bool
{
$valueSegments = explode('/', '/' . $value['path']);
$boundedPrefixSegments = explode('/', rtrim($this->boundedPrefix, '/'));
$howManySegmentsToConsider = min(count($valueSegments), count($boundedPrefixSegments));
$boundedPrefixGlob = implode('/', array_slice($boundedPrefixSegments, 0, $howManySegmentsToConsider));
$valuePathPrefix = implode('/', array_slice($valueSegments, 1, max($howManySegmentsToConsider-1, 0)));
$prefixValue = $value;
$prefixValue['path'] = $valuePathPrefix;
$spec = new Glob($boundedPrefixGlob);
return $spec->isSatisfiedBy($prefixValue);
}

/** @inheritDoc */
public function willBeSatisfiedByEverythingBelow(array $value) : bool
{
if ($this->totalPrefix === null) {
return false;
}
$spec = new Glob(rtrim($this->totalPrefix, '/') . '/**/*');
$terminatedValue = $value;
$terminatedValue['path'] = rtrim($terminatedValue['path'], '/') . '/x/x';
return $spec->isSatisfiedBy($terminatedValue);
}
}
21 changes: 21 additions & 0 deletions src/Specification/InPath.php
Expand Up @@ -14,7 +14,12 @@
namespace Flyfinder\Specification;

use Flyfinder\Path;
use function array_slice;
use function count;
use function explode;
use function implode;
use function in_array;
use function min;
use function preg_match;
use function str_replace;

Expand Down Expand Up @@ -85,4 +90,20 @@ public function isSatisfiedBy(array $value) : bool

return false;
}

/** @inheritDoc */
public function canBeSatisfiedBySomethingBelow(array $value) : bool
{
$pathSegments = explode('/', (string) $this->path);
$valueSegments = explode('/', $value['path']);
$pathPrefixSegments = array_slice($pathSegments, 0, min(count($pathSegments), count($valueSegments)));
$spec = new InPath(new Path(implode('/', $pathPrefixSegments)));
return $spec->isSatisfiedBy($value);
}

/** @inheritDoc */
public function willBeSatisfiedByEverythingBelow(array $value) : bool
{
return $this->isSatisfiedBy($value);
}
}
12 changes: 12 additions & 0 deletions src/Specification/NotSpecification.php
Expand Up @@ -38,4 +38,16 @@ public function isSatisfiedBy(array $value) : bool
{
return !$this->wrapped->isSatisfiedBy($value);
}

/** @inheritDoc */
public function canBeSatisfiedBySomethingBelow(array $value) : bool
{
return !self::thatWillBeSatisfiedByEverythingBelow($this->wrapped, $value);
}

/** @inheritDoc */
public function willBeSatisfiedByEverythingBelow(array $value) : bool
{
return !self::thatCanBeSatisfiedBySomethingBelow($this->wrapped, $value);
}
}

0 comments on commit 817b847

Please sign in to comment.