kahlan/kahlan

View on GitHub
src/Reporter/Coverage/Collector.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
namespace Kahlan\Reporter\Coverage;

use Kahlan\Jit\Parser;
use Kahlan\Dir\Dir;
use Kahlan\Jit\ClassLoader;

class Collector
{
    /**
     * Stack of active collectors.
     *
     * @var array
     */
    protected static $_collectors = [];

    /**
     * Class dependencies.
     *
     * @var array
     */
    protected $_classes = [
        'parser' => Parser::class,
    ];

    /**
     * The driver instance which will log the coverage data.
     *
     * @var object
     */
    protected $_driver = null;

    /**
     * The path(s) which contain the code source files.
     *
     * @var array
     */
    protected $_paths = [];

    /**
     * The base path.
     *
     * @var string
     */
    protected $_base = '';

    /**
     * Some prefix to remove to get the real file path.
     *
     * @var string
     */
    protected $_prefix = '';

    /**
     * Indicate if the filesystem has volumes or not.
     *
     * @var boolean
     */
    protected $_hasVolume = false;

    /**
     * The files presents in `Collector::_paths`.
     *
     * @var array
     */
    protected $_files = [];

    /**
     * The coverage data.
     *
     * @var array
     */
    protected $_coverage = [];

    /**
     * The metrics.
     *
     * @var array
     */
    protected $_metrics = [];

    /**
     * Cache all parsed files
     *
     * @var array
     */
    protected $_tree = [];

    /**
     * Temps cache of processed lines
     *
     * @var array
     */
    protected $_processed = [];

    /**
     * The Constructor.
     *
     * @param array $config Possible options values are:
     *                    - `'driver'` _object_: the driver instance which will log the coverage data.
     *                    - `'path'`   _array_ : the path(s) which contain the code source files.
     *                    - `'base'`   _string_: the base path of the repo (default: `getcwd`).
     *                    - `'prefix'` _string_: some prefix to remove to get the real file path.
     */
    public function __construct($config = [])
    {
        $defaults = [
            'driver'         => null,
            'path'           => [],
            'include'        => '*.php',
            'exclude'        => [],
            'type'           => 'file',
            'skipDots'       => true,
            'leavesOnly'     => false,
            'followSymlinks' => true,
            'recursive'      => true,
            'base'           => getcwd(),
            'hasVolume'      => stripos(PHP_OS, 'WIN') === 0
        ];
        $config += $defaults;

        if ($loader = ClassLoader::instance()) {
            $config += ['prefix' => rtrim($loader->cachePath(), DS)];
        } else {
            $config += ['prefix' => ''];
        }

        $this->_driver = $config['driver'];
        $this->_paths  = (array) $config['path'];
        $this->_base   = $config['base'];
        $this->_prefix = $config['prefix'];
        $this->_hasVolume = $config['hasVolume'];

        $files = Dir::scan($this->_paths, $config);
        foreach ($files as $file) {
            $this->_coverage[realpath($file)] = [];
        }
    }

    /**
     * Gets the used driver.
     *
     * @return object
     */
    public function driver()
    {
        return $this->_driver;
    }

    /**
     * Gets the base path used to compute relative paths.
     *
     * @return string
     */
    public function base()
    {
        return rtrim($this->_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    }

    /**
     * Starts collecting coverage data.
     *
     * @return boolean
     */
    public function start()
    {
        if ($collector = end(static::$_collectors)) {
            $collector->add($collector->_driver->stop());
        }
        static::$_collectors[] = $this;
        $this->_driver->start();
        return true;
    }

    /**
     * Stops collecting coverage data.
     *
     * @return boolean
     */
    public function stop($mergeToParent = true)
    {
        $collector = end(static::$_collectors);
        if ($collector !== $this) {
            return false;
        }
        array_pop(static::$_collectors);
        $collected = $this->_driver->stop();
        $this->add($collected);

        $collector = end(static::$_collectors);
        if (!$collector) {
            return true;
        }
        $collector->add($mergeToParent ? $collected : []);
        $collector->_driver->start();
        return true;
    }

    /**
     * Adds some coverage data to the collector.
     *
     * @param  array $coverage Some coverage data.
     * @return array           The current coverage data.
     */
    public function add($coverage)
    {
        if (!$coverage) {
            return;
        }
        foreach ($coverage as $file => $data) {
            $this->addFile($file, $data);
        }
        return $this->_coverage;
    }

    /**
     * Adds some coverage data to the collector.
     *
     * @param  string $file     A file path.
     * @param  array  $coverage Some coverage related to the file path.
     */
    public function addFile($file, $coverage)
    {
        $file = $this->realpath($file);
        if (!$this->collectable($file)) {
            return;
        }
        $nbLines = count(file($file));

        foreach ($coverage as $line => $value) {
            if ($line === 0 || $line >= $nbLines) {
                continue; // Because Xdebug bugs...
            }
            if (!isset($this->_coverage[$file][$line])) {
                $this->_coverage[$file][$line] = $value;
            } else {
                $this->_coverage[$file][$line] += $value;
            }
        }
    }

    /**
     * Helper for `Collector::addFile()`.
     *
     * @param  string $file     A file path.
     * @param  array  $coverage Some coverage related to the file path.
     */
    protected function _coverage($file, $coverage)
    {
        $result = [];
        $root = $this->parse($file);
        foreach ($root->lines['content'] as $num => $content) {
            $coverable = null;
            foreach ($content['nodes'] as $node) {
                if ($node->coverable && $node->lines['stop'] === $num) {
                    $coverable = $node;
                    break;
                }
            }
            if (!$coverable) {
                continue;
            }
            if (isset($coverage[$num])) {
                $result[$num] = $coverage[$num];
            } elseif (isset($coverable->lines['begin'])) {
                for ($i = $coverable->lines['begin']; $i <= $num; $i++) {
                    if (isset($coverage[$i])) {
                        $result[$num] = $coverage[$i];
                        break;
                    }
                }
                if (!isset($result[$num])) {
                    $result[$num] = 0;
                }
            } else {
                $result[$num] = 0;
            }
        }
        return $result;
    }

    /**
     * Checks if a filename is collectable.
     *
     * @param   string  $file A file path.
     * @return  boolean
     */
    public function collectable($file)
    {
        $file = $this->realpath($file);
        if (preg_match("/eval\(\)'d code$/", $file) || !isset($this->_coverage[$file])) {
            return false;
        }
        return true;
    }

    /**
     * Gets the real path in the original src directory.
     *
     * @param  string $file A file path or cached file path.
     * @return string       The original file path.
     */
    public function realpath($file)
    {
        $prefix = preg_quote($this->_prefix, '~');
        $file = preg_replace("~^{$prefix}~", '', $file);
        if (!$this->_hasVolume) {
            return $file;
        }
        if (preg_match('~^[A-Z]+:~', $file)) {
            return $file;
        }
        $file = ltrim($file, DS);
        $pos = strpos($file, DS);
        if ($pos !== false) {
            $file = substr_replace($file, ':' . DS, $pos, 1);
        }
        return $file;
    }

    /**
     * Exports coverage data.
     *
     * @return array The coverage data.
     */
    public function export($file = null)
    {
        if ($file) {
            return isset($this->_coverage[$file]) ? $this->_coverage($file, $this->_coverage[$file]) : [];
        }
        $result = [];
        $base = preg_quote($this->base(), '~');
        foreach ($this->_coverage as $file => $rawCoverage) {
            if ($coverage = $this->_coverage($file, $rawCoverage)) {
                $result[preg_replace("~^{$base}~", '', $file)] = $coverage;
            }
        }
        return $result;
    }

    /**
     * Gets the collected metrics from coverage data.
     *
     * @return Metrics The collected metrics.
     */
    public function metrics()
    {
        $this->_metrics = new Metrics();
        foreach ($this->_coverage as $file => $rawCoverage) {
            $root = $this->parse($file);
            $coverage = $this->export($file);
            $this->_processed = [
                'loc'      => -1,
                'nlloc'    => -1,
                'lloc'     => -1,
                'cloc'     => -1,
                'coverage' => -1
            ];
            $this->_processTree($file, $root->tree, $coverage);
        }
        return $this->_metrics;
    }

    /**
     * Helper for `Collector::metrics()`.
     *
     * @param  string  $file     The processed file.
     * @param  object  $nodes    The nodes to collect metrics on.
     * @param  array   $coverage The coverage data.
     * @param  string  $path     The naming of the processed node.
     */
    protected function _processTree($file, $nodes, $coverage, $path = '')
    {
        foreach ($nodes as $node) {
            $this->_processNode($file, $node, $coverage, $path);
        }
    }

    /**
     * Helper for `Collector::metrics()`.
     *
     * @param  string  $file     The processed file.
     * @param  object  $node     The node to collect metrics on.
     * @param  array   $coverage The coverage data.
     * @param  string  $path     The naming of the processed node.
     */
    protected function _processNode($file, $node, $coverage, $path)
    {
        if ($node->type === 'namespace') {
            $path = "{$path}" . $node->name . '\\';
            $this->_processTree($file, $node->tree, $coverage, $path);
        } elseif ($node->hasMethods) {
            if ($node->type === 'interface') {
                return;
            }
            $path = "{$path}" . $node->name;
            $this->_processTree($file, $node->tree, $coverage, $path);
        } elseif ($node->type === 'function') {
            $prefix = $node->isMethod ? "{$path}::" : "{$path}";
            $path = $prefix . $node->name . '()';
        } else {
            $this->_processTree($file, $node->tree, $coverage, '');
        }
        $metrics = $this->_processMetrics($file, $node, $coverage);
        $this->_metrics->add($path, $metrics);
    }

    /**
     * Helper for `Collector::metrics()`.
     *
     * @param  string  $file     The processed file.
     * @param  object  $node     The node to collect metrics on.
     * @param  array   $coverage The coverage data.
     * @return array             The collected metrics.
     */
    protected function _processMetrics($file, $node, $coverage)
    {
        $metrics = [
            'loc'      => 0,
            'nlloc'    => 0,
            'lloc'     => 0,
            'cloc'     => 0,
            'coverage' => 0
        ];
        if (!$coverage) {
            return $metrics;
        }
        for ($index = $node->lines['start']; $index <= $node->lines['stop']; $index++) {
            $metrics['loc'] = $this->_lineMetric('loc', $index, $metrics['loc']);
            if (!isset($coverage[$index])) {
                $metrics['nlloc'] = $this->_lineMetric('nlloc', $index, $metrics['nlloc']);
                continue;
            }
            $metrics['lloc'] = $this->_lineMetric('lloc', $index, $metrics['lloc']);
            if ($coverage[$index]) {
                $metrics['cloc'] = $this->_lineMetric('cloc', $index, $metrics['cloc']);
                $metrics['coverage'] = $this->_lineMetric('coverage', $index, $metrics['coverage'], $coverage[$index]);
            }
        }
        $metrics['files'][$file] = $file;
        return $this->_methodMetrics($node, $metrics);
    }

    /**
     * Helper for `Collector::metrics()`.
     *
     * @param  string  $type      The metric type.
     * @param  integer $index     The line index.
     * @param  integer $value     The value to update.
     * @param  integer $increment The increment to perform if the line has not already been processed.
     * @return integer            The metric value.
     */
    protected function _lineMetric($type, $index, $value, $increment = 1)
    {
        if ($this->_processed[$type] >= $index) {
            return $value;
        }
        $this->_processed[$type] = $index;
        $value += $increment;
        return $value;
    }

    /**
     * Helper for `Collector::metrics()`.
     *
     * @param  object  $node    The node to collect metrics on.
     * @param  array   $metrics The metrics of the node.
     * @return array            The updated metrics.
     */
    protected function _methodMetrics($node, $metrics)
    {
        if ($node->type !== 'function' || $node->isClosure) {
            return $metrics;
        }
        $metrics['methods'] = 1;
        if ($metrics['cloc']) {
            $metrics['cmethods'] = 1;
        }

        $metrics['line']['start'] = $node->lines['start'];
        $metrics['line']['stop'] = $node->lines['stop'];
        return $metrics;
    }

    /**
     * Retruns & cache the tree structure of a file.
     *
     * @param string $file the file path to use for building the tree structure.
     */
    public function parse($file)
    {
        if (isset($this->_tree[$file])) {
            return $this->_tree[$file];
        }
        $parser = $this->_classes['parser'];
        return $this->_tree[$file] = $parser::parse(file_get_contents($file), ['lines' => true]);
    }
}