Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add covers annotation support #47

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
TiMESPLiNTER marked this conversation as resolved.
Show resolved Hide resolved
"sebastian/code-unit": "Install code-unit to support @covers annotations in tests."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe adding a not in the README about the @covers support and how to use it can help users.

},
"extra": {
"branch-alias": {
Expand Down
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM php:7.3-cli-alpine3.12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are this DockerFile and docker-compose.yml only required for the local development usage ? I think it can be useful but we must ask some questions :

  • Is 7.3 the correct PHP version ?
  • Do we need to allow testing locally with all the supported PHP version to help tracking bugs ?
  • Why using a docker-compose and not only the DockerFile with a docker run ... ?
  • Is Alpine Linux the correct distribution to use ?

In the GitHub action configuration we are testing the code against a large combination of PHP version. However it's not an exhaustive list only a large one.

Helping developers to work on this lib locally is nice but we need to be sure that this env will match our requirements. Also I think that those changes must not be here in this PR, maybe in another one regarding the development environment. Documentation must also be written to explain how to use the choosen tools.

Copy link
Author

@TiMESPLiNTER TiMESPLiNTER Jun 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's only required for local development. Basically I did it so I don't have to install any PHP extensions on my host but can spin up a local dev/test environment within a few minutes where I can run the unit tests (which need at least xdebug or pcov).

I used PHP 7.3 because it's the minimum requirement of this package (https://github.com/friends-of-phpspec/phpspec-code-coverage/blob/master/composer.json#L41). Of course we need to test it against many other versions but at least I immediately realize during development whether I use a feature that is not available in PHP 7.3 and if the code works on the minimum required PHP version. BC in PHP is quite good so all changes should then run on >7.3 versions smoothly as well.

But I will move it out into another PR.


# 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
2 changes: 1 addition & 1 deletion spec/Listener/CodeCoverageListenerSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
}
}

Expand Down
191 changes: 191 additions & 0 deletions src/Annotation/CoversAnnotationUtil.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

declare(strict_types=1);

namespace FriendsOfPhpSpec\PhpSpec\CodeCoverage\Annotation;

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
{
/**
* @var Registry
*/
private $registry;

public function __construct(Registry $registry)
{
$this->registry = $registry;
}

/**
* @param class-string $className
*
* @throws CodeCoverageException
* @throws InvalidCoversTargetException
* @throws ReflectionException
*
* @return array<string, 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<string, 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<string, mixed>
*/
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<string, 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<string, array<string, mixed>> $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;
}
}
Loading