Upgrade framework

This commit is contained in:
2023-11-14 16:54:35 +01:00
parent 1648a5cd42
commit 4fcf6fffcc
10548 changed files with 693138 additions and 466698 deletions

View File

@@ -0,0 +1 @@
.gitkeep

21
vendor/nunomaduro/collision/LICENSE.md vendored Executable file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Nuno Maduro <enunomaduro@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

81
vendor/nunomaduro/collision/README.md vendored Normal file
View File

@@ -0,0 +1,81 @@
<a href="https://supportukrainenow.org/"><img src="https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg" width="100%"></a>
------
<p align="center">
<img src="https://raw.githubusercontent.com/nunomaduro/collision/stable/docs/logo.png" alt="Collision logo" width="480">
<br>
<img src="https://raw.githubusercontent.com/nunomaduro/collision/stable/docs/example.png" alt="Collision code example" height="300">
</p>
<p align="center">
<a href="https://github.com/nunomaduro/collision/actions"><img src="https://img.shields.io/github/workflow/status/nunomaduro/collision/Tests.svg" alt="Build Status"></img></a>
<a href="https://scrutinizer-ci.com/g/nunomaduro/collision"><img src="https://img.shields.io/scrutinizer/g/nunomaduro/collision.svg" alt="Quality Score"></img></a>
<a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/license.svg" alt="License"></a>
</p>
---
Collision was created by, and is maintained by **[Nuno Maduro](https://github.com/nunomaduro)**, and is a package designed to give you beautiful error reporting when interacting with your app through the command line.
* It's included on **[Laravel](https://laravel.com)**, the most popular free, open-source PHP framework in the world.
* Built on top of the **[Whoops](https://github.com/filp/whoops)** error handler.
* Supports [Laravel](https://github.com/laravel/laravel), [Symfony](https://symfony.com), [PHPUnit](https://github.com/sebastianbergmann/phpunit), and many other frameworks.
## Installation & Usage
> **Requires [PHP 8.0+](https://php.net/releases/)**
Require Collision using [Composer](https://getcomposer.org):
```bash
composer require nunomaduro/collision --dev
```
## Laravel Version Compatibility
Laravel | Collision
:---------|:----------
6.x | 3.x
7.x | 4.x
8.x | 5.x
9.x | 6.x
As an example, here is how to require Collision on Laravel 6.x:
```bash
composer require nunomaduro/collision:^3.0 --dev
```
## Phpunit adapter
Phpunit must be 9.0 or higher.
Add the Collision `printerClass` to your `phpunit.xml` in the `phpunit` section:
```xml
<phpunit
printerClass="NunoMaduro\Collision\Adapters\Phpunit\Printer">
```
## No adapter
You need to register the handler in your code:
```php
(new \NunoMaduro\Collision\Provider)->register();
```
## Contributing
Thank you for considering to contribute to Collision. All the contribution guidelines are mentioned [here](CONTRIBUTING.md).
You can have a look at the [CHANGELOG](CHANGELOG.md) for constant updates & detailed information about the changes. You can also follow the twitter account for latest announcements or just come say hi!: [@enunomaduro](https://twitter.com/enunomaduro)
## License
Collision is an open-sourced software licensed under the [MIT license](LICENSE.md).
Logo by [Caneco](https://twitter.com/caneco).

View File

@@ -0,0 +1,74 @@
{
"name": "nunomaduro/collision",
"description": "Cli error handling for console/command-line PHP applications.",
"keywords": ["console", "command-line", "php", "cli", "error", "handling", "laravel-zero", "laravel", "artisan", "symfony"],
"license": "MIT",
"support": {
"issues": "https://github.com/nunomaduro/collision/issues",
"source": "https://github.com/nunomaduro/collision"
},
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"require": {
"php": "^8.0.0",
"filp/whoops": "^2.14.5",
"symfony/console": "^6.0.2"
},
"require-dev": {
"brianium/paratest": "^6.4.1",
"laravel/framework": "^9.26.1",
"laravel/pint": "^1.1.1",
"nunomaduro/larastan": "^1.0.3",
"nunomaduro/mock-final-classes": "^1.1.0",
"orchestra/testbench": "^7.7",
"phpunit/phpunit": "^9.5.23",
"spatie/ignition": "^1.4.1"
},
"autoload-dev": {
"psr-4": {
"Tests\\Unit\\": "tests/Unit",
"Tests\\FakeProgram\\": "tests/FakeProgram",
"Tests\\": "tests/LaravelApp/tests",
"App\\": "tests/LaravelApp/app/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"NunoMaduro\\Collision\\": "src/"
}
},
"config": {
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"branch-alias": {
"dev-develop": "6.x-dev"
},
"laravel": {
"providers": [
"NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
]
}
},
"scripts": {
"lint": "pint -v",
"test:lint": "pint --test -v",
"test:types": "phpstan analyse --ansi",
"test:unit": "phpunit --colors=always",
"test": [
"@test:lint",
"@test:types",
"@test:unit"
]
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use Illuminate\Support\ServiceProvider;
use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand;
use NunoMaduro\Collision\Contracts\Provider as ProviderContract;
use NunoMaduro\Collision\Handler;
use NunoMaduro\Collision\Provider;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use NunoMaduro\Collision\Writer;
/**
* @internal
*
* @final
*/
class CollisionServiceProvider extends ServiceProvider
{
/**
* {@inheritdoc}
*
* @var bool
*/
protected $defer = true;
/**
* Boots application services.
*
* @return void
*/
public function boot()
{
$this->commands([
TestCommand::class,
]);
}
/**
* {@inheritdoc}
*/
public function register()
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
$this->app->bind(ProviderContract::class, function () {
// @phpstan-ignore-next-line
if ($this->app->has(\Spatie\Ignition\Contracts\SolutionProviderRepository::class)) {
/** @var \Spatie\Ignition\Contracts\SolutionProviderRepository $solutionProviderRepository */
$solutionProviderRepository = $this->app->get(\Spatie\Ignition\Contracts\SolutionProviderRepository::class);
$solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository);
} else {
$solutionsRepository = new NullSolutionsRepository();
}
$writer = new Writer($solutionsRepository);
$handler = new Handler($writer);
return new Provider(null, $handler);
});
/** @var \Illuminate\Contracts\Debug\ExceptionHandler $appExceptionHandler */
$appExceptionHandler = $this->app->make(ExceptionHandlerContract::class);
$this->app->singleton(
ExceptionHandlerContract::class,
function ($app) use ($appExceptionHandler) {
return new ExceptionHandler($app, $appExceptionHandler);
}
);
}
}
/**
* {@inheritdoc}
*/
public function provides()
{
return [ProviderContract::class];
}
}

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Commands;
use Dotenv\Exception\InvalidPathException;
use Dotenv\Parser\Parser;
use Dotenv\Store\StoreBuilder;
use Illuminate\Console\Command;
use Illuminate\Support\Env;
use Illuminate\Support\Str;
use NunoMaduro\Collision\Adapters\Laravel\Exceptions\RequirementsException;
use NunoMaduro\Collision\Coverage;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
/**
* @internal
*
* @final
*/
class TestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test
{--without-tty : Disable output to TTY}
{--coverage : Indicates whether code coverage information should be collected}
{--min= : Indicates the minimum threshold enforcement for code coverage}
{--p|parallel : Indicates if the tests should run in parallel}
{--recreate-databases : Indicates if the test databases should be re-created}
{--drop-databases : Indicates if the test databases should be dropped}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the application tests';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->ignoreValidationErrors();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$phpunitVersion = \PHPUnit\Runner\Version::id();
if ((int) $phpunitVersion[0] === 1) {
throw new RequirementsException('Running PHPUnit v10 or Pest v2 requires Collision ^7.0.');
}
if ((int) $phpunitVersion[0] < 9) {
throw new RequirementsException('Running Collision ^5.0 artisan test command requires at least PHPUnit ^9.0.');
}
// @phpstan-ignore-next-line
if ((int) \Illuminate\Foundation\Application::VERSION[0] < 8) {
throw new RequirementsException('Running Collision ^5.0 artisan test command requires at least Laravel ^8.0.');
}
if ($this->option('coverage') && ! Coverage::isAvailable()) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> ERROR </> Code coverage driver not available.%s</>",
Coverage::usingXdebug()
? " Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?"
: ''
));
$this->newLine();
return 1;
}
if ($this->option('parallel') && ! $this->isParallelDependenciesInstalled()) {
if (! $this->confirm('Running tests in parallel requires "brianium/paratest". Do you wish to install it as a dev dependency?')) {
return 1;
}
$this->installParallelDependencies();
}
$options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2);
$this->clearEnv();
$parallel = $this->option('parallel');
$process = (new Process(array_merge(
// Binary ...
$this->binary(),
// Arguments ...
$parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options)
),
null,
// Envs ...
$parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(),
))->setTimeout(null);
try {
$process->setTty(! $this->option('without-tty'));
} catch (RuntimeException $e) {
$this->output->writeln('Warning: '.$e->getMessage());
}
$exitCode = 1;
try {
$exitCode = $process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
}
if ($exitCode === 0 && $this->option('coverage')) {
if (! $this->usingPest() && $this->option('parallel')) {
$this->newLine();
}
$coverage = Coverage::report($this->output);
$exitCode = (int) ($coverage < $this->option('min'));
if ($exitCode === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
number_format($coverage, 1),
number_format((float) $this->option('min'), 1)
));
}
}
$this->newLine();
return $exitCode;
}
/**
* Get the PHP binary to execute.
*
* @return array
*/
protected function binary()
{
if ($this->usingPest()) {
$command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest'];
} else {
$command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit'];
}
if ('phpdbg' === PHP_SAPI) {
return array_merge([PHP_BINARY, '-qrr'], $command);
}
return array_merge([PHP_BINARY], $command);
}
/**
* Gets the common arguments of PHPUnit and Pest.
*
* @return array
*/
protected function commonArguments()
{
$arguments = [];
if ($this->option('coverage')) {
$arguments[] = '--coverage-php';
$arguments[] = Coverage::getPath();
}
return $arguments;
}
/**
* Determines if Pest is being used.
*
* @return bool
*/
protected function usingPest()
{
return class_exists(\Pest\Laravel\PestServiceProvider::class);
}
/**
* Get the array of arguments for running PHPUnit.
*
* @param array $options
* @return array
*/
protected function phpunitArguments($options)
{
$options = array_merge(['--printer=NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer'], $options);
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '-q'
&& $option != '--quiet'
&& $option != '--coverage'
&& ! Str::startsWith($option, '--min');
}));
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return array_merge($this->commonArguments(), ["--configuration=$file"], $options);
}
/**
* Get the array of arguments for running Paratest.
*
* @param array $options
* @return array
*/
protected function paratestArguments($options)
{
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '--coverage'
&& $option != '-q'
&& $option != '--quiet'
&& ! Str::startsWith($option, '--min')
&& ! Str::startsWith($option, '-p')
&& ! Str::startsWith($option, '--parallel')
&& ! Str::startsWith($option, '--recreate-databases')
&& ! Str::startsWith($option, '--drop-databases');
}));
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return array_merge($this->commonArguments(), [
"--configuration=$file",
"--runner=\Illuminate\Testing\ParallelRunner",
], $options);
}
/**
* Get the array of environment variables for running PHPUnit.
*
* @return array
*/
protected function phpunitEnvironmentVariables()
{
return [];
}
/**
* Get the array of environment variables for running Paratest.
*
* @return array
*/
protected function paratestEnvironmentVariables()
{
return [
'LARAVEL_PARALLEL_TESTING' => 1,
'LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'),
'LARAVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'),
];
}
/**
* Clears any set Environment variables set by Laravel if the --env option is empty.
*
* @return void
*/
protected function clearEnv()
{
if (! $this->option('env')) {
$vars = self::getEnvironmentVariables(
// @phpstan-ignore-next-line
$this->laravel->environmentPath(),
// @phpstan-ignore-next-line
$this->laravel->environmentFile()
);
$repository = Env::getRepository();
foreach ($vars as $name) {
$repository->clear($name);
}
}
}
/**
* @param string $path
* @param string $file
* @return array
*/
protected static function getEnvironmentVariables($path, $file)
{
try {
$content = StoreBuilder::createWithNoNames()
->addPath($path)
->addName($file)
->make()
->read();
} catch (InvalidPathException $e) {
return [];
}
$vars = [];
foreach ((new Parser())->parse($content) as $entry) {
$vars[] = $entry->getName();
}
return $vars;
}
/**
* Check if the parallel dependencies are installed.
*
* @return bool
*/
protected function isParallelDependenciesInstalled()
{
return class_exists(\ParaTest\Console\Commands\ParaTestCommand::class);
}
/**
* Install parallel testing needed dependencies.
*
* @return void
*/
protected function installParallelDependencies()
{
$command = $this->findComposer().' require brianium/paratest --dev';
$process = Process::fromShellCommandline($command, null, null, null, null);
if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) {
try {
$process->setTty(true);
} catch (RuntimeException $e) {
$this->output->writeln('Warning: '.$e->getMessage());
}
}
try {
$process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
}
}
/**
* Get the composer command for the environment.
*
* @return string
*/
protected function findComposer()
{
$composerPath = getcwd().'/composer.phar';
if (file_exists($composerPath)) {
return '"'.PHP_BINARY.'" '.$composerPath;
}
return 'composer';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use NunoMaduro\Collision\Contracts\Provider as ProviderContract;
use Symfony\Component\Console\Exception\ExceptionInterface as SymfonyConsoleExceptionInterface;
use Throwable;
/**
* @internal
*/
final class ExceptionHandler implements ExceptionHandlerContract
{
/**
* Holds an instance of the application exception handler.
*
* @var \Illuminate\Contracts\Debug\ExceptionHandler
*/
protected $appExceptionHandler;
/**
* Holds an instance of the container.
*
* @var \Illuminate\Contracts\Container\Container
*/
protected $container;
/**
* Creates a new instance of the ExceptionHandler.
*/
public function __construct(Container $container, ExceptionHandlerContract $appExceptionHandler)
{
$this->container = $container;
$this->appExceptionHandler = $appExceptionHandler;
}
/**
* {@inheritdoc}
*/
public function report(Throwable $e)
{
$this->appExceptionHandler->report($e);
}
/**
* {@inheritdoc}
*/
public function render($request, Throwable $e)
{
return $this->appExceptionHandler->render($request, $e);
}
/**
* {@inheritdoc}
*/
public function renderForConsole($output, Throwable $e)
{
if ($e instanceof SymfonyConsoleExceptionInterface) {
$this->appExceptionHandler->renderForConsole($output, $e);
} else {
/** @var \NunoMaduro\Collision\Contracts\Provider $provider */
$provider = $this->container->make(ProviderContract::class);
$handler = $provider->register()
->getHandler()
->setOutput($output);
$handler->setInspector((new Inspector($e)));
$handler->handle();
}
}
/**
* Determine if the exception should be reported.
*
* @return bool
*/
public function shouldReport(Throwable $e)
{
return $this->appExceptionHandler->shouldReport($e);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class RequirementsException extends RuntimeException implements RenderlessEditor, RenderlessTrace
{
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
use Throwable;
/**
* @internal
*/
final class IgnitionSolutionsRepository implements SolutionsRepository
{
/**
* Holds an instance of ignition solutions provider repository.
*
* @var \Spatie\Ignition\Contracts\SolutionProviderRepository
*/
protected $solutionProviderRepository;
/**
* IgnitionSolutionsRepository constructor.
*/
public function __construct(SolutionProviderRepository $solutionProviderRepository)
{
$this->solutionProviderRepository = $solutionProviderRepository;
}
/**
* {@inheritdoc}
*/
public function getFromThrowable(Throwable $throwable): array
{
return $this->solutionProviderRepository->getSolutionsForThrowable($throwable);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* This file is part of Collision.
*
* (c) Nuno Maduro <enunomaduro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace NunoMaduro\Collision\Adapters\Laravel;
use Whoops\Exception\Inspector as BaseInspector;
/**
* @internal
*/
final class Inspector extends BaseInspector
{
/**
* {@inheritdoc}
*/
protected function getTrace($e)
{
return $e->getTrace();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* This file is part of Collision.
*
* (c) Nuno Maduro <enunomaduro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace NunoMaduro\Collision\Adapters\Phpunit;
use ReflectionObject;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\Output;
/**
* @internal
*/
final class ConfigureIO
{
/**
* Configures both given input and output with
* options from the environment.
*
* @throws \ReflectionException
*/
public static function of(InputInterface $input, Output $output): void
{
$application = new Application();
$reflector = new ReflectionObject($application);
$method = $reflector->getMethod('configureIO');
$method->setAccessible(true);
$method->invoke($application, $input, $output);
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\Warning;
use ReflectionObject;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Throwable;
/**
* @internal
*/
final class Printer implements \PHPUnit\TextUI\ResultPrinter
{
/**
* Holds an instance of the style.
*
* Style is a class we use to interact with output.
*
* @var Style
*/
private $style;
/**
* Holds the duration time of the test suite.
*
* @var Timer
*/
private $timer;
/**
* Holds the state of the test
* suite. The number of tests, etc.
*
* @var State
*/
private $state;
/**
* If the test suite has failed.
*
* @var bool
*/
private $failed = false;
/**
* Creates a new instance of the listener.
*
* @param ConsoleOutput $output
*
* @throws \ReflectionException
*/
public function __construct(\Symfony\Component\Console\Output\ConsoleOutputInterface $output = null, bool $verbose = false, string $colors = 'always')
{
$this->timer = Timer::start();
$decorated = $colors === 'always' || $colors === 'auto';
$output = $output ?? new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $decorated);
ConfigureIO::of(new ArgvInput(), $output);
$this->style = new Style($output);
$dummyTest = new class() extends TestCase
{
};
$this->state = State::from($dummyTest);
}
/**
* {@inheritdoc}
*/
public function addError(Test $testCase, Throwable $throwable, float $time): void
{
$this->failed = true;
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $throwable));
}
/**
* {@inheritdoc}
*/
public function addWarning(Test $testCase, Warning $warning, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::WARN, $warning));
}
/**
* {@inheritdoc}
*/
public function addFailure(Test $testCase, AssertionFailedError $error, float $time): void
{
$this->failed = true;
$testCase = $this->testCaseFromTest($testCase);
$reflector = new ReflectionObject($error);
if ($reflector->hasProperty('message')) {
$message = trim((string) preg_replace("/\r|\n/", "\n ", $error->getMessage()));
$property = $reflector->getProperty('message');
$property->setAccessible(true);
$property->setValue($error, $message);
}
$this->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $error));
}
/**
* {@inheritdoc}
*/
public function addIncompleteTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::INCOMPLETE, $throwable));
}
/**
* {@inheritdoc}
*/
public function addRiskyTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::RISKY, $throwable));
}
/**
* {@inheritdoc}
*/
public function addSkippedTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::SKIPPED, $throwable));
}
/**
* {@inheritdoc}
*/
public function startTestSuite(TestSuite $suite): void
{
if ($this->state->suiteTotalTests === null) {
$this->state->suiteTotalTests = $suite->count();
}
}
/**
* {@inheritdoc}
*/
public function endTestSuite(TestSuite $suite): void
{
// ..
}
/**
* {@inheritdoc}
*/
public function startTest(Test $testCase): void
{
$testCase = $this->testCaseFromTest($testCase);
// Let's check first if the testCase is over.
if ($this->state->testCaseHasChanged($testCase)) {
$this->style->writeCurrentTestCaseSummary($this->state);
$this->state->moveTo($testCase);
}
}
/**
* {@inheritdoc}
*/
public function endTest(Test $testCase, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
if (! $this->state->existsInTestCase($testCase)) {
$this->state->add(TestResult::fromTestCase($testCase, TestResult::PASS));
}
if ($testCase instanceof TestCase
&& $testCase->getTestResultObject() instanceof \PHPUnit\Framework\TestResult
&& ! $testCase->getTestResultObject()->isStrictAboutOutputDuringTests()
&& ! $testCase->hasExpectationOnOutput()) {
$this->style->write($testCase->getActualOutput());
}
}
/**
* Intentionally left blank as we output things on events of the listener.
*/
public function write(string $content): void
{
// ..
}
/**
* Returns a test case from the given test.
*
* Note: This printer is do not work with normal Test classes - only
* with Test Case classes. Please report an issue if you think
* this should work any other way.
*/
private function testCaseFromTest(Test $test): TestCase
{
if (! $test instanceof TestCase) {
throw new ShouldNotHappen();
}
return $test;
}
/**
* Intentionally left blank as we output things on events of the listener.
*/
public function printResult(\PHPUnit\Framework\TestResult $result): void
{
if ($result->count() === 0) {
$this->style->writeWarning('No tests executed!');
}
$this->style->writeCurrentTestCaseSummary($this->state);
if ($this->failed) {
$onFailure = $this->state->suiteTotalTests !== $this->state->testSuiteTestsCount();
$this->style->writeErrorsSummary($this->state, $onFailure);
}
$this->style->writeRecap($this->state, $this->timer);
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class State
{
/**
* The complete test suite number of tests.
*
* @var int|null
*/
public $suiteTotalTests;
/**
* The complete test suite tests.
*
* @var array<int, TestResult>
*/
public $suiteTests = [];
/**
* The current test case class.
*
* @var string
*/
public $testCaseName;
/**
* The current test case tests.
*
* @var array<int, TestResult>
*/
public $testCaseTests = [];
/**
* The current test case tests.
*
* @var array<int, TestResult>
*/
public $toBePrintedCaseTests = [];
/**
* Header printed.
*
* @var bool
*/
public $headerPrinted = false;
/**
* The state constructor.
*/
private function __construct(string $testCaseName)
{
$this->testCaseName = $testCaseName;
}
/**
* Creates a new State starting from the given test case.
*/
public static function from(TestCase $test): self
{
return new self(self::getPrintableTestCaseName($test));
}
/**
* Adds the given test to the State.
*/
public function add(TestResult $test): void
{
$this->testCaseTests[] = $test;
$this->toBePrintedCaseTests[] = $test;
$this->suiteTests[] = $test;
}
/**
* Gets the test case title.
*/
public function getTestCaseTitle(): string
{
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::FAIL) {
return 'FAIL';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type !== TestResult::PASS) {
return 'WARN';
}
}
return 'PASS';
}
/**
* Gets the test case title color.
*/
public function getTestCaseTitleColor(): string
{
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::FAIL) {
return 'red';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type !== TestResult::PASS) {
return 'yellow';
}
}
return 'green';
}
/**
* Returns the number of tests on the current test case.
*/
public function testCaseTestsCount(): int
{
return count($this->testCaseTests);
}
/**
* Returns the number of tests on the complete test suite.
*/
public function testSuiteTestsCount(): int
{
return count($this->suiteTests);
}
/**
* Checks if the given test case is different from the current one.
*/
public function testCaseHasChanged(TestCase $testCase): bool
{
return self::getPrintableTestCaseName($testCase) !== $this->testCaseName;
}
/**
* Moves the a new test case.
*/
public function moveTo(TestCase $testCase): void
{
$this->testCaseName = self::getPrintableTestCaseName($testCase);
$this->testCaseTests = [];
$this->headerPrinted = false;
}
/**
* Foreach test in the test case.
*/
public function eachTestCaseTests(callable $callback): void
{
foreach ($this->toBePrintedCaseTests as $test) {
$callback($test);
}
$this->toBePrintedCaseTests = [];
}
public function countTestsInTestSuiteBy(string $type): int
{
return count(array_filter($this->suiteTests, function (TestResult $testResult) use ($type) {
return $testResult->type === $type;
}));
}
/**
* Checks if the given test already contains a result.
*/
public function existsInTestCase(TestCase $test): bool
{
foreach ($this->testCaseTests as $testResult) {
if (TestResult::makeDescription($test) === $testResult->description) {
return true;
}
}
return false;
}
/**
* Returns the printable test case name from the given `TestCase`.
*/
public static function getPrintableTestCaseName(TestCase $test): string
{
return $test instanceof HasPrintableTestCaseName
? $test->getPrintableTestCaseName()
: get_class($test);
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use NunoMaduro\Collision\Writer;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExceptionWrapper;
use PHPUnit\Framework\ExpectationFailedException;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
/**
* @internal
*/
final class Style
{
/**
* @var ConsoleOutput
*/
private $output;
/**
* Style constructor.
*/
public function __construct(ConsoleOutputInterface $output)
{
if (! $output instanceof ConsoleOutput) {
throw new ShouldNotHappen();
}
$this->output = $output;
}
/**
* Prints the content.
*/
public function write(string $content): void
{
$this->output->write($content);
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeCurrentTestCaseSummary(State $state): void
{
if ($state->testCaseTestsCount() === 0) {
return;
}
if (! $state->headerPrinted) {
$this->output->writeln($this->titleLineFrom(
$state->getTestCaseTitle() === 'FAIL' ? 'white' : 'black',
$state->getTestCaseTitleColor(),
$state->getTestCaseTitle(),
$state->testCaseName
));
$state->headerPrinted = true;
}
$state->eachTestCaseTests(function (TestResult $testResult) {
$this->output->writeln($this->testLineFrom(
$testResult->color,
$testResult->icon,
$testResult->description,
$testResult->warning
));
});
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeErrorsSummary(State $state, bool $onFailure): void
{
$errors = array_filter($state->suiteTests, function (TestResult $testResult) {
return $testResult->type === TestResult::FAIL;
});
if (! $onFailure) {
$this->output->writeln(['', " \e[2m---\e[22m", '']);
}
array_map(function (TestResult $testResult) use ($onFailure) {
if (! $onFailure) {
$this->output->write(sprintf(
' <fg=red;options=bold>• %s </>> <fg=red;options=bold>%s</>',
$testResult->testCaseName,
$testResult->description
));
}
if (! $testResult->throwable instanceof Throwable) {
throw new ShouldNotHappen();
}
$this->writeError($testResult->throwable);
}, $errors);
}
/**
* Writes the final recap.
*/
public function writeRecap(State $state, Timer $timer = null): void
{
$types = [TestResult::FAIL, TestResult::WARN, TestResult::RISKY, TestResult::INCOMPLETE, TestResult::SKIPPED, TestResult::PASS];
foreach ($types as $type) {
if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) {
$color = TestResult::makeColor($type);
$tests[] = "<fg=$color;options=bold>$countTests $type</>";
}
}
$pending = $state->suiteTotalTests - $state->testSuiteTestsCount();
if ($pending !== 0) {
$tests[] = "\e[2m$pending pending\e[22m";
}
if (! empty($tests)) {
$this->output->write([
"\n",
sprintf(
' <fg=white;options=bold>Tests: </><fg=default>%s</>',
implode(', ', $tests)
),
]);
}
if ($timer !== null) {
$timeElapsed = number_format($timer->result(), 2, '.', '');
$this->output->writeln([
'',
sprintf(
' <fg=white;options=bold>Time: </><fg=default>%ss</>',
$timeElapsed
),
]
);
}
$this->output->writeln('');
}
/**
* Displays a warning message.
*/
public function writeWarning(string $message): void
{
$this->output->writeln($this->testLineFrom('yellow', $message, ''));
}
/**
* Displays the error using Collision's writer
* and terminates with exit code === 1.
*/
public function writeError(Throwable $throwable): void
{
$writer = (new Writer())->setOutput($this->output);
if ($throwable instanceof AssertionFailedError) {
$writer->showTitle(false);
$this->output->write('', true);
}
$writer->ignoreFilesIn([
'/vendor\/bin\/pest/',
'/bin\/pest/',
'/vendor\/pestphp\/pest/',
'/vendor\/phpspec\/prophecy-phpunit/',
'/vendor\/phpspec\/prophecy/',
'/vendor\/phpunit\/phpunit\/src/',
'/vendor\/mockery\/mockery/',
'/vendor\/laravel\/dusk/',
'/vendor\/laravel\/framework\/src\/Illuminate\/Testing/',
'/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Testing/',
'/vendor\/symfony\/framework-bundle\/Test/',
'/vendor\/symfony\/phpunit-bridge/',
'/vendor\/symfony\/dom-crawler/',
'/vendor\/symfony\/browser-kit/',
'/vendor\/symfony\/css-selector/',
'/vendor\/bin\/.phpunit/',
'/bin\/.phpunit/',
'/vendor\/bin\/simple-phpunit/',
'/bin\/phpunit/',
'/vendor\/coduo\/php-matcher\/src\/PHPUnit/',
'/vendor\/sulu\/sulu\/src\/Sulu\/Bundle\/TestBundle\/Testing/',
'/vendor\/webmozart\/assert/',
]);
if ($throwable instanceof ExceptionWrapper && $throwable->getOriginalException() !== null) {
$throwable = $throwable->getOriginalException();
}
$inspector = new Inspector($throwable);
$writer->write($inspector);
if ($throwable instanceof ExpectationFailedException && $comparisionFailure = $throwable->getComparisonFailure()) {
$diff = $comparisionFailure->getDiff();
$lines = explode(PHP_EOL, $diff);
$diff = '';
foreach ($lines as $line) {
if (0 === strpos($line, '-')) {
$line = '<fg=red>'.$line.'</>';
} elseif (0 === strpos($line, '+')) {
$line = '<fg=green>'.$line.'</>';
}
$diff .= $line.PHP_EOL;
}
$diff = trim((string) preg_replace("/\r|\n/", "\n ", $diff));
$this->output->write(" $diff");
}
$this->output->writeln('');
}
/**
* Returns the title contents.
*/
private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName): string
{
return sprintf(
"\n <fg=%s;bg=%s;options=bold> %s </><fg=default> %s</>",
$fg,
$bg,
$title,
$testCaseName
);
}
/**
* Returns the test contents.
*/
private function testLineFrom(string $fg, string $icon, string $description, string $warning = null): string
{
if (! empty($warning)) {
$warning = sprintf(
' → %s',
$warning
);
}
return sprintf(
" <fg=%s;options=bold>%s</><fg=default> \e[2m%s\e[22m</><fg=yellow>%s</>",
$fg,
$icon,
$description,
$warning
);
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use PHPUnit\Framework\TestCase;
use Throwable;
/**
* @internal
*/
final class TestResult
{
public const FAIL = 'failed';
public const SKIPPED = 'skipped';
public const INCOMPLETE = 'incomplete';
public const RISKY = 'risky';
public const WARN = 'warnings';
public const RUNS = 'pending';
public const PASS = 'passed';
/**
* @readonly
*
* @var string
*/
public $testCaseName;
/**
* @readonly
*
* @var string
*/
public $description;
/**
* @readonly
*
* @var string
*/
public $type;
/**
* @readonly
*
* @var string
*/
public $icon;
/**
* @readonly
*
* @var string
*/
public $color;
/**
* @readonly
*
* @var Throwable|null
*/
public $throwable;
/**
* @readonly
*
* @var string
*/
public $warning = '';
/**
* Test constructor.
*/
private function __construct(string $testCaseName, string $description, string $type, string $icon, string $color, Throwable $throwable = null)
{
$this->testCaseName = $testCaseName;
$this->description = $description;
$this->type = $type;
$this->icon = $icon;
$this->color = $color;
$this->throwable = $throwable;
$asWarning = $this->type === TestResult::WARN
|| $this->type === TestResult::RISKY
|| $this->type === TestResult::SKIPPED
|| $this->type === TestResult::INCOMPLETE;
if ($throwable instanceof Throwable && $asWarning) {
$this->warning = trim((string) preg_replace("/\r|\n/", ' ', $throwable->getMessage()));
}
}
/**
* Creates a new test from the given test case.
*/
public static function fromTestCase(TestCase $testCase, string $type, Throwable $throwable = null): self
{
$testCaseName = State::getPrintableTestCaseName($testCase);
$description = self::makeDescription($testCase);
$icon = self::makeIcon($type);
$color = self::makeColor($type);
return new self($testCaseName, $description, $type, $icon, $color, $throwable);
}
/**
* Get the test case description.
*/
public static function makeDescription(TestCase $testCase): string
{
$name = $testCase->getName(false);
if ($testCase instanceof HasPrintableTestCaseName) {
return $name;
}
// First, lets replace underscore by spaces.
$name = str_replace('_', ' ', $name);
// Then, replace upper cases by spaces.
$name = (string) preg_replace('/([A-Z])/', ' $1', $name);
// Finally, if it starts with `test`, we remove it.
$name = (string) preg_replace('/^test/', '', $name);
// Removes spaces
$name = trim($name);
// Lower case everything
$name = mb_strtolower($name);
// Add the dataset name if it has one
if ($dataName = $testCase->dataName()) {
if (is_int($dataName)) {
$name .= sprintf(' with data set #%d', $dataName);
} else {
$name .= sprintf(' with data set "%s"', $dataName);
}
}
return $name;
}
/**
* Get the test case icon.
*/
public static function makeIcon(string $type): string
{
switch ($type) {
case self::FAIL:
return '';
case self::SKIPPED:
return '-';
case self::RISKY:
return '!';
case self::INCOMPLETE:
return '…';
case self::WARN:
return '!';
case self::RUNS:
return '•';
default:
return '✓';
}
}
/**
* Get the test case color.
*/
public static function makeColor(string $type): string
{
switch ($type) {
case self::FAIL:
return 'red';
case self::SKIPPED:
case self::INCOMPLETE:
case self::RISKY:
case self::WARN:
case self::RUNS:
return 'yellow';
default:
return 'green';
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
/**
* @internal
*/
final class Timer
{
/**
* @var float
*/
private $start;
/**
* Timer constructor.
*/
private function __construct(float $start)
{
$this->start = $start;
}
/**
* Starts the timer.
*/
public static function start(): Timer
{
return new self(microtime(true));
}
/**
* Returns the elapsed time in microseconds.
*/
public function result(): float
{
return microtime(true) - $this->start;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Contracts\ArgumentFormatter as ArgumentFormatterContract;
/**
* @internal
*
* @see \Tests\Unit\ArgumentFormatterTest
*/
final class ArgumentFormatter implements ArgumentFormatterContract
{
private const MAX_STRING_LENGTH = 1000;
/**
* {@inheritdoc}
*/
public function format(array $arguments, bool $recursive = true): string
{
$result = [];
foreach ($arguments as $argument) {
switch (true) {
case is_string($argument):
$result[] = '"'.(mb_strlen($argument) > self::MAX_STRING_LENGTH ? mb_substr($argument, 0, self::MAX_STRING_LENGTH).'...' : $argument).'"';
break;
case is_array($argument):
$associative = array_keys($argument) !== range(0, count($argument) - 1);
if ($recursive && $associative && count($argument) <= 5) {
$result[] = '['.$this->format($argument, false).']';
}
break;
case is_object($argument):
$class = get_class($argument);
$result[] = "Object($class)";
break;
}
}
return implode(', ', $result);
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Exceptions\InvalidStyleException;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
/**
* @internal
*/
final class ConsoleColor
{
public const FOREGROUND = 38;
public const BACKGROUND = 48;
public const COLOR256_REGEXP = '~^(bg_)?color_(\d{1,3})$~';
public const RESET_STYLE = 0;
/** @var bool */
private $isSupported;
/** @var bool */
private $forceStyle = false;
/** @var array */
private const STYLES = [
'none' => null,
'bold' => '1',
'dark' => '2',
'italic' => '3',
'underline' => '4',
'blink' => '5',
'reverse' => '7',
'concealed' => '8',
'default' => '39',
'black' => '30',
'red' => '31',
'green' => '32',
'yellow' => '33',
'blue' => '34',
'magenta' => '35',
'cyan' => '36',
'light_gray' => '37',
'dark_gray' => '90',
'light_red' => '91',
'light_green' => '92',
'light_yellow' => '93',
'light_blue' => '94',
'light_magenta' => '95',
'light_cyan' => '96',
'white' => '97',
'bg_default' => '49',
'bg_black' => '40',
'bg_red' => '41',
'bg_green' => '42',
'bg_yellow' => '43',
'bg_blue' => '44',
'bg_magenta' => '45',
'bg_cyan' => '46',
'bg_light_gray' => '47',
'bg_dark_gray' => '100',
'bg_light_red' => '101',
'bg_light_green' => '102',
'bg_light_yellow' => '103',
'bg_light_blue' => '104',
'bg_light_magenta' => '105',
'bg_light_cyan' => '106',
'bg_white' => '107',
];
/** @var array */
private $themes = [];
public function __construct()
{
$this->isSupported = $this->isSupported();
}
/**
* @param string|array $style
* @param string $text
* @return string
*
* @throws InvalidStyleException
* @throws \InvalidArgumentException
*/
public function apply($style, $text)
{
if (! $this->isStyleForced() && ! $this->isSupported()) {
return $text;
}
if (is_string($style)) {
$style = [$style];
}
if (! is_array($style)) {
throw new \InvalidArgumentException('Style must be string or array.');
}
$sequences = [];
foreach ($style as $s) {
if (isset($this->themes[$s])) {
$sequences = array_merge($sequences, $this->themeSequence($s));
} elseif ($this->isValidStyle($s)) {
$sequences[] = $this->styleSequence($s);
} else {
throw new ShouldNotHappen();
}
}
$sequences = array_filter($sequences, function ($val) {
return $val !== null;
});
if (empty($sequences)) {
return $text;
}
return $this->escSequence(implode(';', $sequences)).$text.$this->escSequence(self::RESET_STYLE);
}
/**
* @param bool $forceStyle
*/
public function setForceStyle($forceStyle)
{
$this->forceStyle = $forceStyle;
}
/**
* @return bool
*/
public function isStyleForced()
{
return $this->forceStyle;
}
public function setThemes(array $themes)
{
$this->themes = [];
foreach ($themes as $name => $styles) {
$this->addTheme($name, $styles);
}
}
/**
* @param string $name
* @param array|string $styles
*/
public function addTheme($name, $styles)
{
if (is_string($styles)) {
$styles = [$styles];
}
if (! is_array($styles)) {
throw new \InvalidArgumentException('Style must be string or array.');
}
foreach ($styles as $style) {
if (! $this->isValidStyle($style)) {
throw new InvalidStyleException($style);
}
}
$this->themes[$name] = $styles;
}
/**
* @return array
*/
public function getThemes()
{
return $this->themes;
}
/**
* @param string $name
* @return bool
*/
public function hasTheme($name)
{
return isset($this->themes[$name]);
}
/**
* @param string $name
*/
public function removeTheme($name)
{
unset($this->themes[$name]);
}
/**
* @return bool
*/
public function isSupported()
{
// The COLLISION_FORCE_COLORS variable is for internal purposes only
if (getenv('COLLISION_FORCE_COLORS') !== false) {
return true;
}
if (DIRECTORY_SEPARATOR === '\\') {
return getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON';
}
return function_exists('posix_isatty') && @posix_isatty(STDOUT);
}
/**
* @return bool
*/
public function are256ColorsSupported()
{
if (DIRECTORY_SEPARATOR === '\\') {
return function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT);
}
return strpos(getenv('TERM'), '256color') !== false;
}
/**
* @return array
*/
public function getPossibleStyles()
{
return array_keys(self::STYLES);
}
/**
* @param string $name
* @return string[]
*/
private function themeSequence($name)
{
$sequences = [];
foreach ($this->themes[$name] as $style) {
$sequences[] = $this->styleSequence($style);
}
return $sequences;
}
/**
* @param string $style
* @return string
*/
private function styleSequence($style)
{
if (array_key_exists($style, self::STYLES)) {
return self::STYLES[$style];
}
if (! $this->are256ColorsSupported()) {
return null;
}
preg_match(self::COLOR256_REGEXP, $style, $matches);
$type = $matches[1] === 'bg_' ? self::BACKGROUND : self::FOREGROUND;
$value = $matches[2];
return "$type;5;$value";
}
/**
* @param string $style
* @return bool
*/
private function isValidStyle($style)
{
return array_key_exists($style, self::STYLES) || preg_match(self::COLOR256_REGEXP, $style);
}
/**
* @param string|int $value
* @return string
*/
private function escSequence($value)
{
return "\033[{$value}m";
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts\Adapters\Phpunit;
/**
* @internal
*/
interface HasPrintableTestCaseName
{
/**
* Returns the test case name that should be used by the printer.
*/
public function getPrintableTestCaseName(): string;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts\Adapters\Phpunit;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestListener;
/**
* @internal
*/
interface Listener extends TestListener
{
/**
* Renders the provided error
* on the console.
*
* @return void
*/
public function render(Test $test, \Throwable $t);
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface ArgumentFormatter
{
/**
* Formats the provided array of arguments into
* an understandable description.
*/
public function format(array $arguments, bool $recursive = true): string;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
use Symfony\Component\Console\Output\OutputInterface;
use Whoops\Handler\HandlerInterface;
/**
* @internal
*/
interface Handler extends HandlerInterface
{
/**
* Sets the output.
*
* @return \NunoMaduro\Collision\Contracts\Handler
*/
public function setOutput(OutputInterface $output): Handler;
/**
* Returns the writer.
*
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function getWriter(): Writer;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface Highlighter
{
/**
* Highlights the provided content.
*/
public function highlight(string $content, int $line): string;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface Provider
{
/**
* Registers the current Handler as Error Handler.
*
* @return \NunoMaduro\Collision\Contracts\Provider
*/
public function register(): Provider;
/**
* Returns the handler.
*
* @return \NunoMaduro\Collision\Contracts\Handler
*/
public function getHandler(): Handler;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface RenderlessEditor
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface RenderlessTrace
{
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
use Spatie\Ignition\Contracts\Solution;
use Throwable;
/**
* @internal
*/
interface SolutionsRepository
{
/**
* Gets the solutions from the given `$throwable`.
*
* @return array<int, Solution>
*/
public function getFromThrowable(Throwable $throwable): array;
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* This file is part of Collision.
*
* (c) Nuno Maduro <enunomaduro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace NunoMaduro\Collision\Contracts;
use Symfony\Component\Console\Output\OutputInterface;
use Whoops\Exception\Inspector;
/**
* @internal
*/
interface Writer
{
/**
* Ignores traces where the file string matches one
* of the provided regex expressions.
*
* @param string[] $ignore the regex expressions
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function ignoreFilesIn(array $ignore): Writer;
/**
* Declares whether or not the Writer should show the trace.
*
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function showTrace(bool $show): Writer;
/**
* Declares whether or not the Writer should show the title.
*
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function showTitle(bool $show): Writer;
/**
* Declares whether or not the Writer should show the editor.
*
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function showEditor(bool $show): Writer;
/**
* Writes the details of the exception on the console.
*/
public function write(Inspector $inspector): void;
/**
* Sets the output.
*
* @return \NunoMaduro\Collision\Contracts\Writer
*/
public function setOutput(OutputInterface $output): Writer;
/**
* Gets the output.
*/
public function getOutput(): OutputInterface;
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
/**
* @internal
*/
final class Coverage
{
/**
* Returns the coverage path.
*/
public static function getPath(): string
{
return implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__),
'.temp',
'coverage',
]);
}
/**
* Runs true there is any code coverage driver available.
*/
public static function isAvailable(): bool
{
if (! (new Runtime())->canCollectCodeCoverage()) {
return false;
}
if (static::usingXdebug()) {
$mode = getenv('XDEBUG_MODE') ?: ini_get('xdebug.mode');
return $mode && in_array('coverage', explode(',', $mode), true);
}
return true;
}
/**
* If the user is using Xdebug.
*/
public static function usingXdebug(): bool
{
return (new Runtime())->hasXdebug();
}
/**
* Reports the code coverage report to the
* console and returns the result in float.
*/
public static function report(OutputInterface $output): float
{
if (! file_exists($reportPath = self::getPath())) {
if (self::usingXdebug()) {
$output->writeln(
" <fg=black;bg=yellow;options=bold> WARN </> Unable to get coverage using Xdebug. Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?</>",
);
return 0.0;
}
$output->writeln(
' <fg=black;bg=yellow;options=bold> WARN </> No coverage driver detected.</>',
);
return 0.0;
}
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
$totalWidth = (new Terminal())->getWidth();
$dottedLineLength = $totalWidth;
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
foreach ($report->getIterator() as $file) {
if (! $file instanceof File) {
continue;
}
$dirname = dirname($file->id());
$basename = basename($file->id(), '.php');
$name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$rawName = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$linesExecutedTakenSize = 0;
if ($file->percentageOfExecutedLines()->asString() != '0.00%') {
$linesExecutedTakenSize = strlen($uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)))) + 1;
$name .= sprintf(' <fg=red>%s</>', $uncoveredLines);
}
$percentage = $file->numberOfExecutableLines() === 0
? '100.0'
: number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', '');
$takenSize = strlen($rawName.$percentage) + 8 + $linesExecutedTakenSize; // adding 3 space and percent sign
$percentage = sprintf(
'<fg=%s%s>%s</>',
$percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'),
$percentage === '100.0' ? ';options=bold' : '',
$percentage
);
$output->writeln(sprintf(
' <fg=white>%s</> <fg=#6C7280>%s</> %s <fg=#6C7280>%%</>',
$name,
str_repeat('.', max($dottedLineLength - $takenSize, 1)),
$percentage
));
}
$output->writeln('');
$rawName = 'Total Coverage';
$takenSize = strlen($rawName.$totalCoverage->asString()) + 6;
$output->writeln(sprintf(
' <fg=white;options=bold>%s</> <fg=#6C7280>%s</> %s <fg=#6C7280>%%</>',
$rawName,
str_repeat('.', max($dottedLineLength - $takenSize, 1)),
number_format($totalCoverage->asFloat(), 1, '.', '')
));
return $totalCoverage->asFloat();
}
/**
* Generates an array of missing coverage on the following format:.
*
* ```
* ['11', '20..25', '50', '60..80'];
* ```
*
* @param File $file
* @return array<int, string>
*/
public static function getMissingCoverage($file): array
{
$shouldBeNewLine = true;
$eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array {
if (count($tests) > 0) {
$shouldBeNewLine = true;
return $array;
}
if ($shouldBeNewLine) {
$array[] = (string) $line;
$shouldBeNewLine = false;
return $array;
}
$lastKey = count($array) - 1;
if (array_key_exists($lastKey, $array) && str_contains($array[$lastKey], '..')) {
[$from] = explode('..', $array[$lastKey]);
$array[$lastKey] = $line > $from ? sprintf('%s..%s', $from, $line) : sprintf('%s..%s', $line, $from);
return $array;
}
$array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line);
return $array;
};
$array = [];
foreach (array_filter($file->lineCoverageData(), 'is_array') as $line => $tests) {
$array = $eachLine($array, $tests, $line);
}
return $array;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use RuntimeException;
/**
* @internal
*/
final class InvalidStyleException extends RuntimeException
{
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use RuntimeException;
/**
* @internal
*/
final class ShouldNotHappen extends RuntimeException
{
/**
* @var string
*/
private const MESSAGE = 'This should not happen, please open an issue on collision repository: %s';
public function __construct()
{
parent::__construct(sprintf(self::MESSAGE, 'https://github.com/nunomaduro/collision/issues/new'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Contracts\Handler as HandlerContract;
use NunoMaduro\Collision\Contracts\Writer as WriterContract;
use Symfony\Component\Console\Output\OutputInterface;
use Whoops\Handler\Handler as AbstractHandler;
/**
* @internal
*
* @see \Tests\Unit\HandlerTest
*/
final class Handler extends AbstractHandler implements HandlerContract
{
/**
* Holds an instance of the writer.
*
* @var \NunoMaduro\Collision\Contracts\Writer
*/
protected $writer;
/**
* Creates an instance of the Handler.
*/
public function __construct(WriterContract $writer = null)
{
$this->writer = $writer ?: new Writer();
}
/**
* {@inheritdoc}
*/
public function handle()
{
$this->writer->write($this->getInspector());
return static::QUIT;
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output): HandlerContract
{
$this->writer->setOutput($output);
return $this;
}
/**
* {@inheritdoc}
*/
public function getWriter(): WriterContract
{
return $this->writer;
}
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Contracts\Highlighter as HighlighterContract;
/**
* @internal
*/
final class Highlighter implements HighlighterContract
{
public const TOKEN_DEFAULT = 'token_default';
public const TOKEN_COMMENT = 'token_comment';
public const TOKEN_STRING = 'token_string';
public const TOKEN_HTML = 'token_html';
public const TOKEN_KEYWORD = 'token_keyword';
public const ACTUAL_LINE_MARK = 'actual_line_mark';
public const LINE_NUMBER = 'line_number';
private const ARROW_SYMBOL = '>';
private const DELIMITER = '|';
private const ARROW_SYMBOL_UTF8 = '➜';
private const DELIMITER_UTF8 = '▕'; // '▶';
private const LINE_NUMBER_DIVIDER = 'line_divider';
private const MARKED_LINE_NUMBER = 'marked_line';
private const WIDTH = 3;
/**
* Holds the theme.
*
* @var array
*/
private const THEME = [
self::TOKEN_STRING => ['light_gray'],
self::TOKEN_COMMENT => ['dark_gray', 'italic'],
self::TOKEN_KEYWORD => ['magenta', 'bold'],
self::TOKEN_DEFAULT => ['default', 'bold'],
self::TOKEN_HTML => ['blue', 'bold'],
self::ACTUAL_LINE_MARK => ['red', 'bold'],
self::LINE_NUMBER => ['dark_gray'],
self::MARKED_LINE_NUMBER => ['italic', 'bold'],
self::LINE_NUMBER_DIVIDER => ['dark_gray'],
];
/** @var ConsoleColor */
private $color;
/** @var array */
private const DEFAULT_THEME = [
self::TOKEN_STRING => 'red',
self::TOKEN_COMMENT => 'yellow',
self::TOKEN_KEYWORD => 'green',
self::TOKEN_DEFAULT => 'default',
self::TOKEN_HTML => 'cyan',
self::ACTUAL_LINE_MARK => 'dark_gray',
self::LINE_NUMBER => 'dark_gray',
self::MARKED_LINE_NUMBER => 'dark_gray',
self::LINE_NUMBER_DIVIDER => 'dark_gray',
];
/** @var string */
private $delimiter = self::DELIMITER_UTF8;
/** @var string */
private $arrow = self::ARROW_SYMBOL_UTF8;
/**
* @var string
*/
private const NO_MARK = ' ';
/**
* Creates an instance of the Highlighter.
*/
public function __construct(ConsoleColor $color = null, bool $UTF8 = true)
{
$this->color = $color ?: new ConsoleColor();
foreach (self::DEFAULT_THEME as $name => $styles) {
if (! $this->color->hasTheme($name)) {
$this->color->addTheme($name, $styles);
}
}
foreach (self::THEME as $name => $styles) {
$this->color->addTheme($name, $styles);
}
if (! $UTF8) {
$this->delimiter = self::DELIMITER;
$this->arrow = self::ARROW_SYMBOL;
}
$this->delimiter .= ' ';
}
/**
* {@inheritdoc}
*/
public function highlight(string $content, int $line): string
{
return rtrim($this->getCodeSnippet($content, $line, 4, 4));
}
/**
* @param string $source
* @param int $lineNumber
* @param int $linesBefore
* @param int $linesAfter
*/
public function getCodeSnippet($source, $lineNumber, $linesBefore = 2, $linesAfter = 2): string
{
$tokenLines = $this->getHighlightedLines($source);
$offset = $lineNumber - $linesBefore - 1;
$offset = max($offset, 0);
$length = $linesAfter + $linesBefore + 1;
$tokenLines = array_slice($tokenLines, $offset, $length, $preserveKeys = true);
$lines = $this->colorLines($tokenLines);
return $this->lineNumbers($lines, $lineNumber);
}
/**
* @param string $source
*/
private function getHighlightedLines($source): array
{
$source = str_replace(["\r\n", "\r"], "\n", $source);
$tokens = $this->tokenize($source);
return $this->splitToLines($tokens);
}
/**
* @param string $source
*/
private function tokenize($source): array
{
$tokens = token_get_all($source);
$output = [];
$currentType = null;
$buffer = '';
foreach ($tokens as $token) {
if (is_array($token)) {
switch ($token[0]) {
case T_WHITESPACE:
break;
case T_OPEN_TAG:
case T_OPEN_TAG_WITH_ECHO:
case T_CLOSE_TAG:
case T_STRING:
case T_VARIABLE:
// Constants
case T_DIR:
case T_FILE:
case T_METHOD_C:
case T_DNUMBER:
case T_LNUMBER:
case T_NS_C:
case T_LINE:
case T_CLASS_C:
case T_FUNC_C:
case T_TRAIT_C:
$newType = self::TOKEN_DEFAULT;
break;
case T_COMMENT:
case T_DOC_COMMENT:
$newType = self::TOKEN_COMMENT;
break;
case T_ENCAPSED_AND_WHITESPACE:
case T_CONSTANT_ENCAPSED_STRING:
$newType = self::TOKEN_STRING;
break;
case T_INLINE_HTML:
$newType = self::TOKEN_HTML;
break;
default:
$newType = self::TOKEN_KEYWORD;
}
} else {
$newType = $token === '"' ? self::TOKEN_STRING : self::TOKEN_KEYWORD;
}
if ($currentType === null) {
$currentType = $newType;
}
if ($currentType !== $newType) {
$output[] = [$currentType, $buffer];
$buffer = '';
$currentType = $newType;
}
$buffer .= is_array($token) ? $token[1] : $token;
}
if (isset($newType)) {
$output[] = [$newType, $buffer];
}
return $output;
}
private function splitToLines(array $tokens): array
{
$lines = [];
$line = [];
foreach ($tokens as $token) {
foreach (explode("\n", $token[1]) as $count => $tokenLine) {
if ($count > 0) {
$lines[] = $line;
$line = [];
}
if ($tokenLine === '') {
continue;
}
$line[] = [$token[0], $tokenLine];
}
}
$lines[] = $line;
return $lines;
}
private function colorLines(array $tokenLines): array
{
$lines = [];
foreach ($tokenLines as $lineCount => $tokenLine) {
$line = '';
foreach ($tokenLine as $token) {
[$tokenType, $tokenValue] = $token;
if ($this->color->hasTheme($tokenType)) {
$line .= $this->color->apply($tokenType, $tokenValue);
} else {
$line .= $tokenValue;
}
}
$lines[$lineCount] = $line;
}
return $lines;
}
/**
* @param int|null $markLine
*/
private function lineNumbers(array $lines, $markLine = null): string
{
$lineStrlen = strlen((string) (array_key_last($lines) + 1));
$lineStrlen = $lineStrlen < self::WIDTH ? self::WIDTH : $lineStrlen;
$snippet = '';
$mark = ' '.$this->arrow.' ';
foreach ($lines as $i => $line) {
$coloredLineNumber = $this->coloredLineNumber(self::LINE_NUMBER, $i, $lineStrlen);
if (null !== $markLine) {
$snippet .=
($markLine === $i + 1
? $this->color->apply(self::ACTUAL_LINE_MARK, $mark)
: self::NO_MARK
);
$coloredLineNumber =
($markLine === $i + 1 ?
$this->coloredLineNumber(self::MARKED_LINE_NUMBER, $i, $lineStrlen) :
$coloredLineNumber
);
}
$snippet .= $coloredLineNumber;
$snippet .=
$this->color->apply(self::LINE_NUMBER_DIVIDER, $this->delimiter);
$snippet .= $line.PHP_EOL;
}
return $snippet;
}
/**
* @param string $style
* @param int $i
* @param int $lineStrlen
*/
private function coloredLineNumber($style, $i, $lineStrlen): string
{
return $this->color->apply($style, str_pad((string) ($i + 1), $lineStrlen, ' ', STR_PAD_LEFT));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Contracts\Handler as HandlerContract;
use NunoMaduro\Collision\Contracts\Provider as ProviderContract;
use Whoops\Run;
use Whoops\RunInterface;
/**
* @internal
*
* @see \Tests\Unit\ProviderTest
*/
final class Provider implements ProviderContract
{
/**
* Holds an instance of the Run.
*
* @var \Whoops\RunInterface
*/
protected $run;
/**
* Holds an instance of the handler.
*
* @var \NunoMaduro\Collision\Contracts\Handler
*/
protected $handler;
/**
* Creates a new instance of the Provider.
*/
public function __construct(RunInterface $run = null, HandlerContract $handler = null)
{
$this->run = $run ?: new Run();
$this->handler = $handler ?: new Handler();
}
/**
* {@inheritdoc}
*/
public function register(): ProviderContract
{
$this->run->pushHandler($this->handler)
->register();
return $this;
}
/**
* {@inheritdoc}
*/
public function getHandler(): HandlerContract
{
return $this->handler;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\SolutionsRepositories;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use Throwable;
/**
* @internal
*/
final class NullSolutionsRepository implements SolutionsRepository
{
/**
* {@inheritdoc}
*/
public function getFromThrowable(Throwable $throwable): array
{
return [];
}
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use NunoMaduro\Collision\Contracts\ArgumentFormatter as ArgumentFormatterContract;
use NunoMaduro\Collision\Contracts\Highlighter as HighlighterContract;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use NunoMaduro\Collision\Contracts\Writer as WriterContract;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Whoops\Exception\Frame;
use Whoops\Exception\Inspector;
/**
* @internal
*
* @see \Tests\Unit\WriterTest
*/
final class Writer implements WriterContract
{
/**
* The number of frames if no verbosity is specified.
*/
public const VERBOSITY_NORMAL_FRAMES = 1;
/**
* Holds an instance of the solutions repository.
*
* @var \NunoMaduro\Collision\Contracts\SolutionsRepository
*/
private $solutionsRepository;
/**
* Holds an instance of the Output.
*
* @var \Symfony\Component\Console\Output\OutputInterface
*/
protected $output;
/**
* Holds an instance of the Argument Formatter.
*
* @var \NunoMaduro\Collision\Contracts\ArgumentFormatter
*/
protected $argumentFormatter;
/**
* Holds an instance of the Highlighter.
*
* @var \NunoMaduro\Collision\Contracts\Highlighter
*/
protected $highlighter;
/**
* Ignores traces where the file string matches one
* of the provided regex expressions.
*
* @var string[]
*/
protected $ignore = [];
/**
* Declares whether or not the trace should appear.
*
* @var bool
*/
protected $showTrace = true;
/**
* Declares whether or not the title should appear.
*
* @var bool
*/
protected $showTitle = true;
/**
* Declares whether or not the editor should appear.
*
* @var bool
*/
protected $showEditor = true;
/**
* Creates an instance of the writer.
*/
public function __construct(
SolutionsRepository $solutionsRepository = null,
OutputInterface $output = null,
ArgumentFormatterContract $argumentFormatter = null,
HighlighterContract $highlighter = null
) {
$this->solutionsRepository = $solutionsRepository ?: new NullSolutionsRepository();
$this->output = $output ?: new ConsoleOutput();
$this->argumentFormatter = $argumentFormatter ?: new ArgumentFormatter();
$this->highlighter = $highlighter ?: new Highlighter();
}
/**
* {@inheritdoc}
*/
public function write(Inspector $inspector): void
{
$this->renderTitleAndDescription($inspector);
$frames = $this->getFrames($inspector);
$editorFrame = array_shift($frames);
$exception = $inspector->getException();
if ($this->showEditor
&& $editorFrame !== null
&& ! $exception instanceof RenderlessEditor
) {
$this->renderEditor($editorFrame);
}
$this->renderSolution($inspector);
if ($this->showTrace && ! empty($frames) && ! $exception instanceof RenderlessTrace) {
$this->renderTrace($frames);
} elseif (! $exception instanceof RenderlessEditor) {
$this->output->writeln('');
}
}
/**
* {@inheritdoc}
*/
public function ignoreFilesIn(array $ignore): WriterContract
{
$this->ignore = $ignore;
return $this;
}
/**
* {@inheritdoc}
*/
public function showTrace(bool $show): WriterContract
{
$this->showTrace = $show;
return $this;
}
/**
* {@inheritdoc}
*/
public function showTitle(bool $show): WriterContract
{
$this->showTitle = $show;
return $this;
}
/**
* {@inheritdoc}
*/
public function showEditor(bool $show): WriterContract
{
$this->showEditor = $show;
return $this;
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output): WriterContract
{
$this->output = $output;
return $this;
}
/**
* {@inheritdoc}
*/
public function getOutput(): OutputInterface
{
return $this->output;
}
/**
* Returns pertinent frames.
*/
protected function getFrames(Inspector $inspector): array
{
return $inspector->getFrames()
->filter(
function ($frame) {
// If we are in verbose mode, we always
// display the full stack trace.
if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
return true;
}
foreach ($this->ignore as $ignore) {
// Ensure paths are linux-style (like the ones on $this->ignore)
// @phpstan-ignore-next-line
$sanitizedPath = (string) str_replace('\\', '/', $frame->getFile());
if (preg_match($ignore, $sanitizedPath)) {
return false;
}
}
return true;
}
)
->getArray();
}
/**
* Renders the title of the exception.
*/
protected function renderTitleAndDescription(Inspector $inspector): WriterContract
{
$exception = $inspector->getException();
$message = rtrim($exception->getMessage());
$class = $inspector->getExceptionName();
if ($this->showTitle) {
$this->render("<bg=red;options=bold> $class </>");
$this->output->writeln('');
}
$this->output->writeln("<fg=default;options=bold> $message</>");
return $this;
}
/**
* Renders the solution of the exception, if any.
*/
protected function renderSolution(Inspector $inspector): WriterContract
{
$throwable = $inspector->getException();
$solutions = $this->solutionsRepository->getFromThrowable($throwable);
foreach ($solutions as $solution) {
/** @var \Spatie\Ignition\Contracts\Solution $solution */
$title = $solution->getSolutionTitle();
$description = $solution->getSolutionDescription();
$links = $solution->getDocumentationLinks();
$description = trim((string) preg_replace("/\n/", "\n ", $description));
$this->render(sprintf(
'<fg=cyan;options=bold>i</> <fg=default;options=bold>%s</>: %s %s',
rtrim($title, '.'),
$description,
implode(', ', array_map(function (string $link) {
return sprintf("\n <fg=gray>%s</>", $link);
}, $links))
));
}
return $this;
}
/**
* Renders the editor containing the code that was the
* origin of the exception.
*/
protected function renderEditor(Frame $frame): WriterContract
{
if ($frame->getFile() !== 'Unknown') {
$file = $this->getFileRelativePath((string) $frame->getFile());
// getLine() might return null so cast to int to get 0 instead
$line = (int) $frame->getLine();
$this->render('at <fg=green>'.$file.'</>'.':<fg=green>'.$line.'</>');
$content = $this->highlighter->highlight((string) $frame->getFileContents(), (int) $frame->getLine());
$this->output->writeln($content);
}
return $this;
}
/**
* Renders the trace of the exception.
*/
protected function renderTrace(array $frames): WriterContract
{
$vendorFrames = 0;
$userFrames = 0;
foreach ($frames as $i => $frame) {
if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE && strpos($frame->getFile(), '/vendor/') !== false) {
$vendorFrames++;
continue;
}
if ($userFrames > static::VERBOSITY_NORMAL_FRAMES && $this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
break;
}
$userFrames++;
$file = $this->getFileRelativePath($frame->getFile());
$line = $frame->getLine();
$class = empty($frame->getClass()) ? '' : $frame->getClass().'::';
$function = $frame->getFunction();
$args = $this->argumentFormatter->format($frame->getArgs());
$pos = str_pad((string) ((int) $i + 1), 4, ' ');
if ($vendorFrames > 0) {
$this->output->write(
sprintf("\n \e[2m+%s vendor frames \e[22m", $vendorFrames)
);
$vendorFrames = 0;
}
$this->render("<fg=yellow>$pos</><fg=default;options=bold>$file</>:<fg=default;options=bold>$line</>");
$this->render("<fg=gray> $class$function($args)</>", false);
}
return $this;
}
/**
* Renders an message into the console.
*
* @return $this
*/
protected function render(string $message, bool $break = true): WriterContract
{
if ($break) {
$this->output->writeln('');
}
$this->output->writeln(" $message");
return $this;
}
/**
* Returns the relative path of the given file path.
*/
protected function getFileRelativePath(string $filePath): string
{
$cwd = (string) getcwd();
if (! empty($cwd)) {
return str_replace("$cwd/", '', $filePath);
}
return $filePath;
}
}