From 38a617eee08208fecdd55c3ca26ccb0af8322dbc Mon Sep 17 00:00:00 2001 From: TiMESPLiNTER Date: Wed, 21 Apr 2021 10:31:57 +0200 Subject: [PATCH 1/4] Add covers annotation support Signed-off-by: TiMESPLiNTER --- composer.json | 7 +- grumphp.yml.dist | 14 +- src/Annotation/CoversAnnotationUtil.php | 164 ++++++++++++++ src/Annotation/DocBlock.php | 209 ++++++++++++++++++ src/Annotation/Registry.php | 70 ++++++ src/Exception/CodeCoverageException.php | 9 + .../InvalidCoversTargetException.php | 9 + src/Listener/CodeCoverageListener.php | 28 ++- 8 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 src/Annotation/CoversAnnotationUtil.php create mode 100644 src/Annotation/DocBlock.php create mode 100644 src/Annotation/Registry.php create mode 100644 src/Exception/CodeCoverageException.php create mode 100644 src/Exception/InvalidCoversTargetException.php diff --git a/composer.json b/composer.json index e3325a4..410c06a 100644 --- a/composer.json +++ b/composer.json @@ -46,12 +46,15 @@ "sebastian/comparator": "< 2.0" }, "require-dev": { + "ext-json": "*", "drupol/php-conventions": "^3.0", - "vimeo/psalm": "^4.7" + "vimeo/psalm": "^4.7", + "sebastian/code-unit": "^1.0.8" }, "suggest": { "ext-pcov": "Install PCov extension to generate code coverage.", - "ext-xdebug": "Install Xdebug to generate phpspec code coverage." + "ext-xdebug": "Install Xdebug to generate phpspec code coverage.", + "sebastian/code-unit": "Install code-unit to support @covers annotations in tests." }, "extra": { "branch-alias": { diff --git a/grumphp.yml.dist b/grumphp.yml.dist index daef422..f0681ac 100644 --- a/grumphp.yml.dist +++ b/grumphp.yml.dist @@ -1,7 +1,7 @@ -imports: - - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } - -parameters: - extra_tasks: - phpspec: - verbose: true +#imports: +# - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } +# +#parameters: +# extra_tasks: +# phpspec: +# verbose: true diff --git a/src/Annotation/CoversAnnotationUtil.php b/src/Annotation/CoversAnnotationUtil.php new file mode 100644 index 0000000..3fda4e9 --- /dev/null +++ b/src/Annotation/CoversAnnotationUtil.php @@ -0,0 +1,164 @@ +registry = $registry; + } + + /** + * @throws CodeCoverageException + * + * @return array|bool + */ + public function getLinesToBeCovered(string $className, string $methodName) + { + $annotations = self::parseTestMethodAnnotations( + $className, + $methodName + ); + + if (!$this->shouldCoversAnnotationBeUsed($annotations)) { + return false; + } + + return $this->getLinesToBeCoveredOrUsed($className, $methodName, 'covers'); + } + + /** + * Returns lines of code specified with the @uses annotation. + * + * @throws CodeCoverageException + */ + public function getLinesToBeUsed(string $className, string $methodName): array + { + return $this->getLinesToBeCoveredOrUsed($className, $methodName, 'uses'); + } + + public function parseTestMethodAnnotations(string $className, ?string $methodName = ''): array + { + if ($methodName !== null) { + try { + return [ + 'method' => $this->registry->forMethod($className, $methodName)->symbolAnnotations(), + 'class' => $this->registry->forClassName($className)->symbolAnnotations(), + ]; + } catch (\ReflectionException $methodNotFound) { + // ignored + } + } + + return [ + 'method' => null, + 'class' => $this->registry->forClassName($className)->symbolAnnotations(), + ]; + } + + /** + * @param string $className + * @param string $methodName + * @param string $mode + * @return array + * @throws CodeCoverageException + */ + private function getLinesToBeCoveredOrUsed(string $className, string $methodName, string $mode): array + { + $annotations = $this->parseTestMethodAnnotations( + $className, + $methodName + ); + + $classShortcut = null; + + if (!empty($annotations['class'][$mode . 'DefaultClass'])) { + if (count($annotations['class'][$mode . 'DefaultClass']) > 1) { + throw new CodeCoverageException( + sprintf( + 'More than one @%sClass annotation in class or interface "%s".', + $mode, + $className + ) + ); + } + + $classShortcut = $annotations['class'][$mode . 'DefaultClass'][0]; + } + + $list = $annotations['class'][$mode] ?? []; + + if (isset($annotations['method'][$mode])) { + $list = array_merge($list, $annotations['method'][$mode]); + } + + $codeUnits = CodeUnitCollection::fromArray([]); + $mapper = new Mapper(); + + foreach (array_unique($list) as $element) { + if ($classShortcut && strncmp($element, '::', 2) === 0) { + $element = $classShortcut . $element; + } + + $element = preg_replace('/[\s()]+$/', '', $element); + $element = explode(' ', $element); + $element = $element[0]; + + if ($mode === 'covers' && interface_exists($element)) { + throw new InvalidCoversTargetException( + sprintf( + 'Trying to @cover interface "%s".', + $element + ) + ); + } + + try { + $codeUnits = $codeUnits->mergeWith($mapper->stringToCodeUnits($element)); + } catch (InvalidCodeUnitException $e) { + throw new InvalidCoversTargetException( + sprintf( + '"@%s %s" is invalid', + $mode, + $element + ), + (int) $e->getCode(), + $e + ); + } + } + + return $mapper->codeUnitsToSourceLines($codeUnits); + } + + private function shouldCoversAnnotationBeUsed(array $annotations): bool + { + if (isset($annotations['method']['coversNothing'])) { + return false; + } + + if (isset($annotations['method']['covers'])) { + return true; + } + + if (isset($annotations['class']['coversNothing'])) { + return false; + } + + return true; + } +} diff --git a/src/Annotation/DocBlock.php b/src/Annotation/DocBlock.php new file mode 100644 index 0000000..308663a --- /dev/null +++ b/src/Annotation/DocBlock.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation; + +use function array_map; +use function array_merge; +use function array_slice; +use function array_values; +use function count; +use function file; +use function preg_match; +use function preg_match_all; +use function strtolower; +use function substr; +use ReflectionClass; +use ReflectionFunctionAbstract; +use ReflectionMethod; +use Reflector; + +/** + * This is an abstraction around a PHPUnit-specific docBlock, + * allowing us to ask meaningful questions about a specific + * reflection symbol. + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class DocBlock +{ + /** @var string */ + private $docComment; + + /** @var bool */ + private $isMethod; + + /** @var array> pre-parsed annotations indexed by name and occurrence index */ + private $symbolAnnotations; + + /** @var int */ + private $startLine; + + /** @var int */ + private $endLine; + + /** @var string */ + private $fileName; + + /** @var string */ + private $name; + + /** + * @var string + * + * @psalm-var class-string + */ + private $className; + + public static function ofClass(ReflectionClass $class): self + { + $className = $class->getName(); + + return new self( + (string) $class->getDocComment(), + false, + self::extractAnnotationsFromReflector($class), + $class->getStartLine(), + $class->getEndLine(), + $class->getFileName(), + $className, + $className + ); + } + + /** + * @psalm-param class-string $classNameInHierarchy + */ + public static function ofMethod(ReflectionMethod $method, string $classNameInHierarchy): self + { + return new self( + (string) $method->getDocComment(), + true, + self::extractAnnotationsFromReflector($method), + $method->getStartLine(), + $method->getEndLine(), + $method->getFileName(), + $method->getName(), + $classNameInHierarchy + ); + } + + /** + * Note: we do not preserve an instance of the reflection object, since it cannot be safely (de-)serialized. + * + * @param string $docComment + * @param bool $isMethod + * @param array> $symbolAnnotations + * @param int $startLine + * @param int $endLine + * @param string $fileName + * @param string $name + * @param string $className + */ + private function __construct( + string $docComment, + bool $isMethod, + array $symbolAnnotations, + int $startLine, + int $endLine, + string $fileName, + string $name, + string $className + ) { + $this->docComment = $docComment; + $this->isMethod = $isMethod; + $this->symbolAnnotations = $symbolAnnotations; + $this->startLine = $startLine; + $this->endLine = $endLine; + $this->fileName = $fileName; + $this->name = $name; + $this->className = $className; + } + + /** + * @psalm-return array + */ + public function getInlineAnnotations(): array + { + $code = file($this->fileName); + $lineNumber = $this->startLine; + $startLine = $this->startLine - 1; + $endLine = $this->endLine - 1; + $codeLines = array_slice($code, $startLine, $endLine - $startLine + 1); + $annotations = []; + + foreach ($codeLines as $line) { + if (preg_match('#/\*\*?\s*@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?\*/$#m', $line, $matches)) { + $annotations[strtolower($matches['name'])] = [ + 'line' => $lineNumber, + 'value' => $matches['value'], + ]; + } + + $lineNumber++; + } + + return $annotations; + } + + public function symbolAnnotations(): array + { + return $this->symbolAnnotations; + } + + /** + * @param string $docBlock + * @return array> + */ + private static function parseDocBlock(string $docBlock): array + { + // Strip away the docblock header and footer to ease parsing of one line annotations + $docBlock = (string) substr($docBlock, 3, -2); + $annotations = []; + + if (preg_match_all('/@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?$/m', $docBlock, $matches)) { + $numMatches = count($matches[0]); + + for ($i = 0; $i < $numMatches; $i++) { + $annotations[$matches['name'][$i]][] = (string) $matches['value'][$i]; + } + } + + return $annotations; + } + + /** + * @param ReflectionClass|ReflectionFunctionAbstract $reflector + * @return array + */ + private static function extractAnnotationsFromReflector(Reflector $reflector): array + { + $annotations = []; + + if ($reflector instanceof ReflectionClass) { + $annotations = array_merge( + $annotations, + ...array_map( + static function (ReflectionClass $trait): array { + return self::parseDocBlock((string) $trait->getDocComment()); + }, + array_values($reflector->getTraits()) + ) + ); + } + + return array_merge( + $annotations, + self::parseDocBlock((string) $reflector->getDocComment()) + ); + } +} diff --git a/src/Annotation/Registry.php b/src/Annotation/Registry.php new file mode 100644 index 0000000..dfb5dad --- /dev/null +++ b/src/Annotation/Registry.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation; + +use function array_key_exists; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; + +/** + * Reflection information, and therefore DocBlock information, is static within + * a single PHP process. It is therefore okay to use a Singleton registry here. + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class Registry +{ + /** + * @var array indexed by class name + */ + private $classDocBlocks = []; + + /** + * @var array> indexed by class name and method name + */ + private $methodDocBlocks = []; + + /** + * @param string $class + * @return DocBlock + * @throws ReflectionException + */ + public function forClassName(string $class): DocBlock + { + if (array_key_exists($class, $this->classDocBlocks)) { + return $this->classDocBlocks[$class]; + } + + $reflection = new ReflectionClass($class); + + return $this->classDocBlocks[$class] = DocBlock::ofClass($reflection); + } + + /** + * @param string $classInHierarchy + * @param string $method + * @return DocBlock + * @throws ReflectionException + */ + public function forMethod(string $classInHierarchy, string $method): DocBlock + { + if (isset($this->methodDocBlocks[$classInHierarchy][$method])) { + return $this->methodDocBlocks[$classInHierarchy][$method]; + } + + $reflection = new ReflectionMethod($classInHierarchy, $method); + + return $this->methodDocBlocks[$classInHierarchy][$method] = DocBlock::ofMethod($reflection, $classInHierarchy); + } +} diff --git a/src/Exception/CodeCoverageException.php b/src/Exception/CodeCoverageException.php new file mode 100644 index 0000000..9ad919b --- /dev/null +++ b/src/Exception/CodeCoverageException.php @@ -0,0 +1,9 @@ +skipCoverage = $skipCoverage; + $this->coversUtil = new CoversAnnotationUtil(new Registry()); } public function afterExample(ExampleEvent $event): void @@ -84,7 +92,25 @@ public function afterExample(ExampleEvent $event): void return; } - $this->coverage->stop(); + if (!class_exists('SebastianBergmann\CodeUnit\InterfaceUnit')) { + $this->coverage->stop(); + return; + } + + $testFunctionName = $event->getExample()->getFunctionReflection()->getName(); + $testClassName = $event->getSpecification()->getClassReflection()->getName(); + + $linesToBeCovered = $this->coversUtil->getLinesToBeCovered( + $testClassName, + $testFunctionName + ); + + $linesToBeUsed = $this->coversUtil->getLinesToBeUsed( + $testClassName, + $testFunctionName + ); + + $this->coverage->stop(true, $linesToBeCovered, $linesToBeUsed); } public function afterSuite(SuiteEvent $event): void From 767c238d2cb9816eb4b634ded3fd4b4b67f437d8 Mon Sep 17 00:00:00 2001 From: TiMESPLiNTER Date: Wed, 21 Apr 2021 11:28:39 +0200 Subject: [PATCH 2/4] Remove ext-json dev requirement Signed-off-by: TiMESPLiNTER --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 410c06a..f90da2a 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,6 @@ "sebastian/comparator": "< 2.0" }, "require-dev": { - "ext-json": "*", "drupol/php-conventions": "^3.0", "vimeo/psalm": "^4.7", "sebastian/code-unit": "^1.0.8" From cb95c2d94448d26704c10f1a95208d965e8dd70e Mon Sep 17 00:00:00 2001 From: TiMESPLiNTER Date: Wed, 21 Apr 2021 12:03:02 +0200 Subject: [PATCH 3/4] Small fix Signed-off-by: TiMESPLiNTER --- src/Annotation/CoversAnnotationUtil.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Annotation/CoversAnnotationUtil.php b/src/Annotation/CoversAnnotationUtil.php index 3fda4e9..1ba5803 100644 --- a/src/Annotation/CoversAnnotationUtil.php +++ b/src/Annotation/CoversAnnotationUtil.php @@ -29,7 +29,7 @@ public function __construct(Registry $registry) */ public function getLinesToBeCovered(string $className, string $methodName) { - $annotations = self::parseTestMethodAnnotations( + $annotations = $this->parseTestMethodAnnotations( $className, $methodName ); From 8d6496448d80ba53e5cbe5a1079b4648811b5b2a Mon Sep 17 00:00:00 2001 From: TiMESPLiNTER Date: Fri, 23 Apr 2021 11:39:49 +0200 Subject: [PATCH 4/4] Makegrumphp happy --- composer.json | 6 +- docker-compose.yml | 11 + docker/Dockerfile | 15 ++ grumphp.yml.dist | 14 +- spec/Listener/CodeCoverageListenerSpec.php | 2 +- src/Annotation/CoversAnnotationUtil.php | 47 ++++- src/Annotation/DocBlock.php | 225 ++++++++++++--------- src/Annotation/Registry.php | 13 +- src/CodeCoverageExtension.php | 17 +- src/Exception/CodeCoverageException.php | 4 +- src/Listener/CodeCoverageListener.php | 27 +-- 11 files changed, 249 insertions(+), 132 deletions(-) create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile diff --git a/composer.json b/composer.json index f90da2a..fab0513 100644 --- a/composer.json +++ b/composer.json @@ -47,13 +47,13 @@ }, "require-dev": { "drupol/php-conventions": "^3.0", - "vimeo/psalm": "^4.7", - "sebastian/code-unit": "^1.0.8" + "sebastian/code-unit": "^1.0.8", + "vimeo/psalm": "^4.7" }, "suggest": { "ext-pcov": "Install PCov extension to generate code coverage.", "ext-xdebug": "Install Xdebug to generate phpspec code coverage.", - "sebastian/code-unit": "Install code-unit to support @covers annotations in tests." + "sebastian/code-unit": "Install code-unit to support @covers annotations in tests." }, "extra": { "branch-alias": { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b776985 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.2' +services: + php: + build: + context: ./ + dockerfile: docker/Dockerfile + tty: true + hostname: phpspec-code-coverage-php + container_name: phpspec-code-coverage-php + volumes: + - ./:/var/www/html diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c7ebfe9 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM php:7.3-cli-alpine3.12 + +# SYS: Install required packages +RUN apk --no-cache upgrade && \ + apk --no-cache add bash git sudo openssh autoconf gcc g++ make gettext make + +# COMPOSER: install binary +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer + +# PHP: Install php extensions +RUN pecl channel-update pecl.php.net && \ + pecl install pcov && \ + docker-php-ext-enable pcov + +WORKDIR /var/www/html diff --git a/grumphp.yml.dist b/grumphp.yml.dist index f0681ac..daef422 100644 --- a/grumphp.yml.dist +++ b/grumphp.yml.dist @@ -1,7 +1,7 @@ -#imports: -# - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } -# -#parameters: -# extra_tasks: -# phpspec: -# verbose: true +imports: + - { resource: vendor/drupol/php-conventions/config/php73/grumphp.yml } + +parameters: + extra_tasks: + phpspec: + verbose: true diff --git a/spec/Listener/CodeCoverageListenerSpec.php b/spec/Listener/CodeCoverageListenerSpec.php index 39ece71..e1cdda8 100644 --- a/spec/Listener/CodeCoverageListenerSpec.php +++ b/spec/Listener/CodeCoverageListenerSpec.php @@ -90,7 +90,7 @@ public function let(ConsoleIO $io) { $codeCoverage = new CodeCoverage(new DriverStub(), new Filter()); - $this->beConstructedWith($io, $codeCoverage, []); + $this->beConstructedWith($io, $codeCoverage, null, []); } } diff --git a/src/Annotation/CoversAnnotationUtil.php b/src/Annotation/CoversAnnotationUtil.php index 1ba5803..2758ff6 100644 --- a/src/Annotation/CoversAnnotationUtil.php +++ b/src/Annotation/CoversAnnotationUtil.php @@ -6,10 +6,13 @@ use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Exception\CodeCoverageException; use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Exception\InvalidCoversTargetException; +use ReflectionException; use SebastianBergmann\CodeUnit\CodeUnitCollection; use SebastianBergmann\CodeUnit\InvalidCodeUnitException; use SebastianBergmann\CodeUnit\Mapper; +use function count; + final class CoversAnnotationUtil { /** @@ -23,9 +26,13 @@ public function __construct(Registry $registry) } /** + * @param class-string $className + * * @throws CodeCoverageException + * @throws InvalidCoversTargetException + * @throws ReflectionException * - * @return array|bool + * @return array|false */ public function getLinesToBeCovered(string $className, string $methodName) { @@ -42,24 +49,39 @@ public function getLinesToBeCovered(string $className, string $methodName) } /** - * Returns lines of code specified with the @uses annotation. + * Returns lines of code specified with the. + * + * @param class-string $className . * * @throws CodeCoverageException + * @throws InvalidCoversTargetException + * @throws ReflectionException + * + * @return array + * + * @uses annotation. */ public function getLinesToBeUsed(string $className, string $methodName): array { return $this->getLinesToBeCoveredOrUsed($className, $methodName, 'uses'); } + /** + * @param class-string $className + * + * @throws ReflectionException + * + * @return array + */ public function parseTestMethodAnnotations(string $className, ?string $methodName = ''): array { - if ($methodName !== null) { + if (null !== $methodName) { try { return [ 'method' => $this->registry->forMethod($className, $methodName)->symbolAnnotations(), - 'class' => $this->registry->forClassName($className)->symbolAnnotations(), + 'class' => $this->registry->forClassName($className)->symbolAnnotations(), ]; - } catch (\ReflectionException $methodNotFound) { + } catch (ReflectionException $methodNotFound) { // ignored } } @@ -71,11 +93,13 @@ public function parseTestMethodAnnotations(string $className, ?string $methodNam } /** - * @param string $className - * @param string $methodName - * @param string $mode - * @return array + * @param class-string $className + * * @throws CodeCoverageException + * @throws InvalidCoversTargetException + * @throws ReflectionException + * + * @return array */ private function getLinesToBeCoveredOrUsed(string $className, string $methodName, string $mode): array { @@ -118,7 +142,7 @@ private function getLinesToBeCoveredOrUsed(string $className, string $methodName $element = explode(' ', $element); $element = $element[0]; - if ($mode === 'covers' && interface_exists($element)) { + if ('covers' === $mode && interface_exists($element)) { throw new InvalidCoversTargetException( sprintf( 'Trying to @cover interface "%s".', @@ -145,6 +169,9 @@ private function getLinesToBeCoveredOrUsed(string $className, string $methodName return $mapper->codeUnitsToSourceLines($codeUnits); } + /** + * @param array> $annotations + */ private function shouldCoversAnnotationBeUsed(array $annotations): bool { if (isset($annotations['method']['coversNothing'])) { diff --git a/src/Annotation/DocBlock.php b/src/Annotation/DocBlock.php index 308663a..0b29123 100644 --- a/src/Annotation/DocBlock.php +++ b/src/Annotation/DocBlock.php @@ -10,8 +10,14 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation; +use Exception; +use ReflectionClass; +use ReflectionMethod; +use Reflector; + use function array_map; use function array_merge; use function array_slice; @@ -22,10 +28,6 @@ use function preg_match_all; use function strtolower; use function substr; -use ReflectionClass; -use ReflectionFunctionAbstract; -use ReflectionMethod; -use Reflector; /** * This is an abstraction around a PHPUnit-specific docBlock, @@ -36,78 +38,50 @@ */ final class DocBlock { - /** @var string */ - private $docComment; - - /** @var bool */ - private $isMethod; - - /** @var array> pre-parsed annotations indexed by name and occurrence index */ - private $symbolAnnotations; + /** + * @var string + */ + private $className; - /** @var int */ - private $startLine; + /** + * @var string + */ + private $docComment; - /** @var int */ + /** + * @var int + */ private $endLine; - /** @var string */ + /** + * @var string + */ private $fileName; - /** @var string */ - private $name; + /** + * @var bool + */ + private $isMethod; /** * @var string - * - * @psalm-var class-string */ - private $className; - - public static function ofClass(ReflectionClass $class): self - { - $className = $class->getName(); + private $name; - return new self( - (string) $class->getDocComment(), - false, - self::extractAnnotationsFromReflector($class), - $class->getStartLine(), - $class->getEndLine(), - $class->getFileName(), - $className, - $className - ); - } + /** + * @var int + */ + private $startLine; /** - * @psalm-param class-string $classNameInHierarchy + * @var array> pre-parsed annotations indexed by name and occurrence index */ - public static function ofMethod(ReflectionMethod $method, string $classNameInHierarchy): self - { - return new self( - (string) $method->getDocComment(), - true, - self::extractAnnotationsFromReflector($method), - $method->getStartLine(), - $method->getEndLine(), - $method->getFileName(), - $method->getName(), - $classNameInHierarchy - ); - } + private $symbolAnnotations; /** * Note: we do not preserve an instance of the reflection object, since it cannot be safely (de-)serialized. * - * @param string $docComment - * @param bool $isMethod * @param array> $symbolAnnotations - * @param int $startLine - * @param int $endLine - * @param string $fileName - * @param string $name - * @param string $className */ private function __construct( string $docComment, @@ -119,71 +93,116 @@ private function __construct( string $name, string $className ) { - $this->docComment = $docComment; - $this->isMethod = $isMethod; + $this->docComment = $docComment; + $this->isMethod = $isMethod; $this->symbolAnnotations = $symbolAnnotations; - $this->startLine = $startLine; - $this->endLine = $endLine; - $this->fileName = $fileName; - $this->name = $name; - $this->className = $className; + $this->startLine = $startLine; + $this->endLine = $endLine; + $this->fileName = $fileName; + $this->name = $name; + $this->className = $className; } /** - * @psalm-return array + * @throws Exception + * + * @return array */ public function getInlineAnnotations(): array { - $code = file($this->fileName); - $lineNumber = $this->startLine; - $startLine = $this->startLine - 1; - $endLine = $this->endLine - 1; - $codeLines = array_slice($code, $startLine, $endLine - $startLine + 1); + if (false === $code = file($this->fileName)) { + throw new Exception(sprintf('Could not read file `%s`', $this->fileName)); + } + + $lineNumber = $this->startLine; + $startLine = $this->startLine - 1; + $endLine = $this->endLine - 1; + $codeLines = array_slice($code, $startLine, $endLine - $startLine + 1); $annotations = []; + $pattern = '#/\*\*?\s*@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?\*/$#m'; foreach ($codeLines as $line) { - if (preg_match('#/\*\*?\s*@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?\*/$#m', $line, $matches)) { + if (preg_match($pattern, $line, $matches)) { $annotations[strtolower($matches['name'])] = [ - 'line' => $lineNumber, + 'line' => $lineNumber, 'value' => $matches['value'], ]; } - $lineNumber++; + ++$lineNumber; } return $annotations; } - public function symbolAnnotations(): array + /** + * @param ReflectionClass $class + * + * @throws Exception + * + * @return static + */ + public static function ofClass(ReflectionClass $class): self { - return $this->symbolAnnotations; + $className = $class->getName(); + + $startLine = $class->getStartLine(); + $endLine = $class->getEndLine(); + $fileName = $class->getFileName(); + + if (false === $startLine || false === $endLine || false === $fileName) { + throw new Exception('Could not get required information from class'); + } + + return new self( + (string) $class->getDocComment(), + false, + self::extractAnnotationsFromReflector($class), + $startLine, + $endLine, + $fileName, + $className, + $className + ); } /** - * @param string $docBlock - * @return array> + * @throws Exception + * + * @return static */ - private static function parseDocBlock(string $docBlock): array + public static function ofMethod(ReflectionMethod $method, string $classNameInHierarchy): self { - // Strip away the docblock header and footer to ease parsing of one line annotations - $docBlock = (string) substr($docBlock, 3, -2); - $annotations = []; - - if (preg_match_all('/@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?$/m', $docBlock, $matches)) { - $numMatches = count($matches[0]); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $fileName = $method->getFileName(); - for ($i = 0; $i < $numMatches; $i++) { - $annotations[$matches['name'][$i]][] = (string) $matches['value'][$i]; - } + if (false === $startLine || false === $endLine || false === $fileName) { + throw new Exception('Could not get required information from class'); } - return $annotations; + return new self( + (string) $method->getDocComment(), + true, + self::extractAnnotationsFromReflector($method), + $startLine, + $endLine, + $fileName, + $method->getName(), + $classNameInHierarchy + ); + } + + /** + * @return array> + */ + public function symbolAnnotations(): array + { + return $this->symbolAnnotations; } - /** - * @param ReflectionClass|ReflectionFunctionAbstract $reflector - * @return array + /** + * @return array */ private static function extractAnnotationsFromReflector(Reflector $reflector): array { @@ -201,9 +220,33 @@ static function (ReflectionClass $trait): array { ); } + if (!$reflector instanceof ReflectionClass && !$reflector instanceof ReflectionMethod) { + return $annotations; + } + return array_merge( $annotations, self::parseDocBlock((string) $reflector->getDocComment()) ); } + + /** + * @return array> + */ + private static function parseDocBlock(string $docBlock): array + { + // Strip away the docblock header and footer to ease parsing of one line annotations + $docBlock = (string) substr($docBlock, 3, -2); + $annotations = []; + + if (preg_match_all('/@(?P[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?$/m', $docBlock, $matches)) { + $numMatches = count($matches[0]); + + for ($i = 0; $i < $numMatches; ++$i) { + $annotations[$matches['name'][$i]][] = (string) $matches['value'][$i]; + } + } + + return $annotations; + } } diff --git a/src/Annotation/Registry.php b/src/Annotation/Registry.php index dfb5dad..c980295 100644 --- a/src/Annotation/Registry.php +++ b/src/Annotation/Registry.php @@ -10,13 +10,15 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation; -use function array_key_exists; use ReflectionClass; use ReflectionException; use ReflectionMethod; +use function array_key_exists; + /** * Reflection information, and therefore DocBlock information, is static within * a single PHP process. It is therefore okay to use a Singleton registry here. @@ -36,8 +38,8 @@ final class Registry private $methodDocBlocks = []; /** - * @param string $class - * @return DocBlock + * @param class-string $class + * * @throws ReflectionException */ public function forClassName(string $class): DocBlock @@ -52,9 +54,8 @@ public function forClassName(string $class): DocBlock } /** - * @param string $classInHierarchy - * @param string $method - * @return DocBlock + * @param class-string $classInHierarchy + * * @throws ReflectionException */ public function forMethod(string $classInHierarchy, string $method): DocBlock diff --git a/src/CodeCoverageExtension.php b/src/CodeCoverageExtension.php index b2627d9..650bfb0 100644 --- a/src/CodeCoverageExtension.php +++ b/src/CodeCoverageExtension.php @@ -14,6 +14,8 @@ namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage; +use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation\CoversAnnotationUtil; +use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation\Registry; use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Exception\NoCoverageDriverAvailableException; use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Listener\CodeCoverageListener; use PhpSpec\Console\ConsoleIO; @@ -166,7 +168,20 @@ public function load(ServiceContainer $container, array $params = []): void /** @var array $codeCoverageReports */ $codeCoverageReports = $container->get('code_coverage.reports'); - $listener = new CodeCoverageListener($consoleIO, $codeCoverage, $codeCoverageReports, $skipCoverage); + $coversAnnotationUtil = null; + + if (class_exists('SebastianBergmann\CodeUnit\InterfaceUnit')) { + $coversAnnotationUtil = new CoversAnnotationUtil(new Registry()); + } + + $listener = new CodeCoverageListener( + $consoleIO, + $codeCoverage, + $coversAnnotationUtil, + $codeCoverageReports, + $skipCoverage + ); + $listener->setOptions($container->getParam('code_coverage', [])); return $listener; diff --git a/src/Exception/CodeCoverageException.php b/src/Exception/CodeCoverageException.php index 9ad919b..cad9592 100644 --- a/src/Exception/CodeCoverageException.php +++ b/src/Exception/CodeCoverageException.php @@ -4,6 +4,8 @@ namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Exception; -class CodeCoverageException extends \Exception +use Exception; + +class CodeCoverageException extends Exception { } diff --git a/src/Listener/CodeCoverageListener.php b/src/Listener/CodeCoverageListener.php index 77f1ca9..338cbef 100644 --- a/src/Listener/CodeCoverageListener.php +++ b/src/Listener/CodeCoverageListener.php @@ -14,7 +14,6 @@ namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Listener; -use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation\Registry; use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation\CoversAnnotationUtil; use FriendsOfPhpSpec\PhpSpec\CodeCoverage\Exception\ConfigurationException; use PhpSpec\Console\ConsoleIO; @@ -38,6 +37,11 @@ class CodeCoverageListener implements EventSubscriberInterface */ private $coverage; + /** + * @var CoversAnnotationUtil|null + */ + private $coversUtil; + /** * @var ConsoleIO */ @@ -59,17 +63,15 @@ class CodeCoverageListener implements EventSubscriberInterface private $skipCoverage; /** - * @var CoversAnnotationUtil - */ - private $coversUtil; - - /** - * CodeCoverageListener constructor. - * * @param array $reports */ - public function __construct(ConsoleIO $io, CodeCoverage $coverage, array $reports, bool $skipCoverage = false) - { + public function __construct( + ConsoleIO $io, + CodeCoverage $coverage, + ?CoversAnnotationUtil $coversAnnotationUtil, + array $reports, + bool $skipCoverage = false + ) { $this->io = $io; $this->coverage = $coverage; $this->reports = $reports; @@ -83,7 +85,7 @@ public function __construct(ConsoleIO $io, CodeCoverage $coverage, array $report ]; $this->skipCoverage = $skipCoverage; - $this->coversUtil = new CoversAnnotationUtil(new Registry()); + $this->coversUtil = $coversAnnotationUtil; } public function afterExample(ExampleEvent $event): void @@ -92,8 +94,9 @@ public function afterExample(ExampleEvent $event): void return; } - if (!class_exists('SebastianBergmann\CodeUnit\InterfaceUnit')) { + if (null === $this->coversUtil) { $this->coverage->stop(); + return; }