diff --git a/composer.json b/composer.json index e3325a4..fab0513 100644 --- a/composer.json +++ b/composer.json @@ -47,11 +47,13 @@ }, "require-dev": { "drupol/php-conventions": "^3.0", + "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." + "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/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/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 new file mode 100644 index 0000000..2758ff6 --- /dev/null +++ b/src/Annotation/CoversAnnotationUtil.php @@ -0,0 +1,191 @@ +registry = $registry; + } + + /** + * @param class-string $className + * + * @throws CodeCoverageException + * @throws InvalidCoversTargetException + * @throws ReflectionException + * + * @return array|false + */ + public function getLinesToBeCovered(string $className, string $methodName) + { + $annotations = $this->parseTestMethodAnnotations( + $className, + $methodName + ); + + if (!$this->shouldCoversAnnotationBeUsed($annotations)) { + return false; + } + + return $this->getLinesToBeCoveredOrUsed($className, $methodName, 'covers'); + } + + /** + * 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 (null !== $methodName) { + 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 class-string $className + * + * @throws CodeCoverageException + * @throws InvalidCoversTargetException + * @throws ReflectionException + * + * @return array + */ + 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 ('covers' === $mode && 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); + } + + /** + * @param array> $annotations + */ + 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..0b29123 --- /dev/null +++ b/src/Annotation/DocBlock.php @@ -0,0 +1,252 @@ + + * + * 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; +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; + +/** + * 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 $className; + + /** + * @var string + */ + private $docComment; + + /** + * @var int + */ + private $endLine; + + /** + * @var string + */ + private $fileName; + + /** + * @var bool + */ + private $isMethod; + + /** + * @var string + */ + private $name; + + /** + * @var int + */ + private $startLine; + + /** + * @var array> pre-parsed annotations indexed by name and occurrence index + */ + private $symbolAnnotations; + + /** + * Note: we do not preserve an instance of the reflection object, since it cannot be safely (de-)serialized. + * + * @param array> $symbolAnnotations + */ + 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; + } + + /** + * @throws Exception + * + * @return array + */ + public function getInlineAnnotations(): array + { + 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($pattern, $line, $matches)) { + $annotations[strtolower($matches['name'])] = [ + 'line' => $lineNumber, + 'value' => $matches['value'], + ]; + } + + ++$lineNumber; + } + + return $annotations; + } + + /** + * @param ReflectionClass $class + * + * @throws Exception + * + * @return static + */ + public static function ofClass(ReflectionClass $class): self + { + $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 + ); + } + + /** + * @throws Exception + * + * @return static + */ + public static function ofMethod(ReflectionMethod $method, string $classNameInHierarchy): self + { + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $fileName = $method->getFileName(); + + if (false === $startLine || false === $endLine || false === $fileName) { + throw new Exception('Could not get required information from class'); + } + + 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; + } + + /** + * @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()) + ) + ); + } + + 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 new file mode 100644 index 0000000..c980295 --- /dev/null +++ b/src/Annotation/Registry.php @@ -0,0 +1,71 @@ + + * + * 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 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. + * + * @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 class-string $class + * + * @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 class-string $classInHierarchy + * + * @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/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 new file mode 100644 index 0000000..cad9592 --- /dev/null +++ b/src/Exception/CodeCoverageException.php @@ -0,0 +1,11 @@ + $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; @@ -76,6 +85,7 @@ public function __construct(ConsoleIO $io, CodeCoverage $coverage, array $report ]; $this->skipCoverage = $skipCoverage; + $this->coversUtil = $coversAnnotationUtil; } public function afterExample(ExampleEvent $event): void @@ -84,7 +94,26 @@ public function afterExample(ExampleEvent $event): void return; } - $this->coverage->stop(); + if (null === $this->coversUtil) { + $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