Skip to content
Draft
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
66 changes: 0 additions & 66 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -240,72 +240,6 @@ parameters:
count: 1
path: src/Configuration/ServerVariables.php

-
message: '#^Cannot access property \$uri on Illuminate\\Routing\\Route\|null\.$#'
identifier: property.nonObject
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Cannot call method getAction\(\) on Illuminate\\Routing\\Route\|null\.$#'
identifier: method.nonObject
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Cannot call method getRoute\(\) on Dedoc\\Scramble\\Exceptions\\RouteAware\|null\.$#'
identifier: method.nonObject
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Cannot call method methods\(\) on Illuminate\\Routing\\Route\|null\.$#'
identifier: method.nonObject
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Method Dedoc\\Scramble\\Console\\Commands\\AnalyzeDocumentation\:\:groupExceptions\(\) has parameter \$exceptions with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Method Dedoc\\Scramble\\Console\\Commands\\AnalyzeDocumentation\:\:groupExceptions\(\) should return Illuminate\\Support\\Collection\<string, Illuminate\\Support\\Collection\<int, Throwable\>\> but returns Illuminate\\Support\\Collection\<\(int\|string\), Illuminate\\Support\\Collection\<int, mixed\>\>\.$#'
identifier: return.type
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Parameter \#1 \$api of static method Dedoc\\Scramble\\Scramble\:\:getGeneratorConfig\(\) expects string, array\|bool\|float\|int\|string\|null given\.$#'
identifier: argument.type
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Parameter \#1 \$exceptions of method Dedoc\\Scramble\\Console\\Commands\\AnalyzeDocumentation\:\:renderRouteExceptionsGroupLine\(\) expects Illuminate\\Support\\Collection\<int, Dedoc\\Scramble\\Exceptions\\RouteAware\>, Illuminate\\Support\\Collection\<int, Throwable\> given\.$#'
identifier: argument.type
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#'
identifier: argument.type
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Part \$action \(mixed\) of encapsed string cannot be cast to string\.$#'
identifier: encapsedStringPart.nonString
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Part \$message \(iterable\<string\>\|string\) of encapsed string cannot be cast to string\.$#'
identifier: encapsedStringPart.nonString
count: 1
path: src/Console/Commands/AnalyzeDocumentation.php

-
message: '#^Parameter \#1 \$content of method NunoMaduro\\Collision\\Highlighter\:\:highlight\(\) expects string, string\|false given\.$#'
identifier: argument.type
Expand Down
213 changes: 168 additions & 45 deletions src/Console/Commands/AnalyzeDocumentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

namespace Dedoc\Scramble\Console\Commands;

use Dedoc\Scramble\Console\Commands\Components\Block;
use Dedoc\Scramble\Console\Commands\Components\TermsOfContentItem;
use Dedoc\Scramble\Diagnostics\CodedDiagnostic;
use Dedoc\Scramble\Diagnostics\Diagnostic;
use Dedoc\Scramble\Diagnostics\DiagnosticSeverity;
use Dedoc\Scramble\Exceptions\ConsoleRenderable;
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Routing\Route;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;

class AnalyzeDocumentation extends Command
{
Expand All @@ -25,75 +28,151 @@ public function handle(Generator $generator): int
{
$generator->setThrowExceptions(false);

$generator(Scramble::getGeneratorConfig($this->option('api')));
$apiOption = $this->option('api');
$api = is_string($apiOption) ? $apiOption : 'default';

$i = 1;
$this->groupExceptions($generator->exceptions)->each(function (Collection $exceptions, string $group) use (&$i) {
$this->renderExceptionsGroup($exceptions, $group, $i);
});
$generator(Scramble::getGeneratorConfig($api));

$context = $generator->context;
assert($context instanceof OpenApiContext);

/** @var Collection<int, Diagnostic> $diagnostics */
$diagnostics = $context->diagnostics->diagnostics;

$this->groupDiagnosticsByRoute($diagnostics)
->sortKeysUsing(static function (string $a, string $b): int {
if ($a === '') {
return $b === '' ? 0 : 1;
}
if ($b === '') {
return -1;
}

return strcmp($a, $b);
})
->each(function (Collection $routeDiagnostics, string $routeKey) {
$this->renderRouteDiagnosticsGroup($routeDiagnostics, $routeKey);
});

if (count($generator->exceptions)) {
$this->error('[ERROR] Found '.count($generator->exceptions).' errors.');
$errorCount = $diagnostics->filter(fn (Diagnostic $d) => $d->severity() === DiagnosticSeverity::Error)->count();
$warningCount = $diagnostics->filter(fn (Diagnostic $d) => $d->severity() === DiagnosticSeverity::Warning)->count();

if ($errorCount > 0) {
$this->error($this->formatSummary($errorCount, $warningCount, isError: true));

return static::FAILURE;
}

if ($warningCount > 0) {
$this->warn($this->formatSummary($errorCount, $warningCount, isError: false));

return static::SUCCESS;
}

$this->info('Everything is fine! Documentation is generated without any errors 🍻');

return static::SUCCESS;
}

private function formatSummary(int $errors, int $warnings, bool $isError): string
{
$errorLabel = $errors.' '.Str::plural('error', $errors);
$warningLabel = $warnings.' '.Str::plural('warning', $warnings);

$bracket = $isError ? 'ERROR' : 'WARNING';

return "[$bracket] Found $errorLabel, $warningLabel.";
}

/**
* @return Collection<string, Collection<int, Throwable>>
* @param Collection<int, Diagnostic> $diagnostics
* @return Collection<string, Collection<int, Diagnostic>>
*/
private function groupExceptions(array $exceptions): Collection
private function groupDiagnosticsByRoute(Collection $diagnostics): Collection
{
return collect($exceptions)
->groupBy(fn ($e) => $e instanceof RouteAware ? $this->getRouteKey($e->getRoute()) : '');
return $diagnostics->groupBy(function (Diagnostic $d) {
$route = $d->route();

return $route ? $this->getRouteKey($route) : '';
});
}

/**
* @param Collection<int, Throwable> $exceptions
* @param Collection<int, Diagnostic> $routeDiagnostics
*/
private function renderExceptionsGroup(Collection $exceptions, string $group, int &$i): void
private function renderRouteDiagnosticsGroup(Collection $routeDiagnostics, string $routeKey): void
{
// when route key is set, then the exceptions in the group are route aware.
if ($group) {
$this->renderRouteExceptionsGroupLine($exceptions);
if ($routeKey !== '') {
$this->renderRouteDiagnosticsHeader($routeDiagnostics);
}

$exceptions->each(function ($exception) use (&$i) {
$this->renderException($exception, $i);
$i++;
$byCategory = $routeDiagnostics->groupBy(fn (Diagnostic $d) => $d->category() ?: 'General')->sortKeys();

$byCategory->each(function (Collection $categoryDiagnostics, string $category) {
$this->line("<options=bold>{$category}</>");
$this->line('');

$this->renderSeveritySection($categoryDiagnostics, DiagnosticSeverity::Error, 'Errors');
$this->renderSeveritySection($categoryDiagnostics, DiagnosticSeverity::Warning, 'Warnings');

$this->line('');
});
}

private function getRouteKey(?Route $route): string
/**
* @param Collection<int, Diagnostic> $categoryDiagnostics
*/
private function renderSeveritySection(Collection $categoryDiagnostics, DiagnosticSeverity $severity, string $label): void
{
if (! $route) {
return '';
$section = $categoryDiagnostics->filter(fn (Diagnostic $d) => $d->severity() === $severity);
if ($section->isEmpty()) {
return;
}

$method = implode('|', $route->methods());
$action = $route->getAction('uses');
$this->line("{$label} ({$section->count()})");
$this->line('');

$byContext = $section->groupBy(fn (Diagnostic $d) => $d->context() ?: 'General')->sortKeys();

return "$method.$action";
$byContext->each(function (Collection $items, string $context) {
$this->line(" {$context}: ");
$this->line('');

$items->each(function (Diagnostic $d) {
$this->renderDiagnosticEntry($d);
$this->line('');
});
});
}

/**
* @param Collection<int, RouteAware> $exceptions
* @param Collection<int, Diagnostic> $routeDiagnostics
*/
private function renderRouteExceptionsGroupLine(Collection $exceptions): void
private function renderRouteDiagnosticsHeader(Collection $routeDiagnostics): void
{
$firstException = $exceptions->first();
$route = $firstException->getRoute();
$first = $routeDiagnostics->first(fn (Diagnostic $d) => $d->route() !== null);
if (! $first instanceof Diagnostic || ! $route = $first->route()) {
return;
}

$method = implode('|', $route->methods());
$errorsMessage = ($count = $exceptions->count()).' '.Str::plural('error', $count);
$errorCount = $routeDiagnostics->filter(fn (Diagnostic $d) => $d->severity() === DiagnosticSeverity::Error)->count();
$warningCount = $routeDiagnostics->filter(fn (Diagnostic $d) => $d->severity() === DiagnosticSeverity::Warning)->count();

$statsParts = [];
if ($errorCount > 0) {
$statsParts[] = '<fg=red>'.$errorCount.' '.Str::plural('error', $errorCount).'</>';
}
if ($warningCount > 0) {
$statsParts[] = '<fg=yellow>'.$warningCount.' '.Str::plural('warning', $warningCount).'</>';
}

$stats = implode(', ', $statsParts);

$right = '<options=bold;fg='.$this->getHttpMethodColor($method).'>'.$method."</> $route->uri $stats";

$tocComponent = new TermsOfContentItem(
right: '<options=bold;fg='.$this->getHttpMethodColor($method).'>'.$method."</> $route->uri <fg=red>$errorsMessage</>",
right: $right,
left: $this->getRouteAction($route),
);

Expand All @@ -102,6 +181,56 @@ private function renderRouteExceptionsGroupLine(Collection $exceptions): void
$this->line('');
}

private function renderDiagnosticEntry(Diagnostic $d): void
{
$pad = 4;

if ($d instanceof CodedDiagnostic) {
$message = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', $d->message());
$lines = explode("\n", $message);
$first = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', $lines[0]);
$continuationLines = array_slice($lines, 1);

(new Block(
"<options=bold>[{$d->code()}] {$first}</>",
$pad,
))->render($this->output);

foreach ($continuationLines as $line) {
(new Block($line, $pad))->render($this->output);
}

if ($d->tip() !== '') {
(new Block("Tip: {$d->tip()}", $pad))->render($this->output);
}

(new Block("Docs: {$d->documentationUrl()}", $pad))->render($this->output);

return;
}

$msg = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', $d->message());
(new Block($msg, $pad))->render($this->output);

$exception = $d->toException();
if ($exception instanceof ConsoleRenderable) {
$exception->renderInConsole($this->output);
}
}

private function getRouteKey(?Route $route): string
{
if (! $route) {
return '';
}

$method = implode('|', $route->methods());
$uses = $route->getAction('uses');
$actionPart = is_string($uses) ? $uses : '';

return $method.'.'.$actionPart;
}

private function getHttpMethodColor(string $method): string
{
return match ($method) {
Expand All @@ -113,7 +242,12 @@ private function getHttpMethodColor(string $method): string

public function getRouteAction(?Route $route): ?string
{
if (! $uses = $route->getAction('uses')) {
if (! $route) {
return null;
}

$uses = $route->getAction('uses');
if (! $uses || ! is_string($uses)) {
return null;
}

Expand All @@ -127,15 +261,4 @@ public function getRouteAction(?Route $route): ?string

return "<fg=gray>{$eloquentClassName}@{$method}</>";
}

private function renderException(Throwable $exception, int $i): void
{
$message = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', property_exists($exception, 'originalMessage') ? $exception->originalMessage : $exception->getMessage()); // @phpstan-ignore argument.templateType

$this->output->writeln("<options=bold>$i. {$message}</>");

if ($exception instanceof ConsoleRenderable) {
$exception->renderInConsole($this->output);
}
}
}
29 changes: 29 additions & 0 deletions src/Console/Commands/Components/Block.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Dedoc\Scramble\Console\Commands\Components;

use Illuminate\Console\OutputStyle;
use Symfony\Component\Console\Terminal;

class Block implements Component
{
public function __construct(
private string $content,
private int $paddingLeft = 0,
) {}

public function render(OutputStyle $style): void
{
if ($this->content === '') {
return;
}

$padding = str_repeat(' ', $this->paddingLeft);
$width = max(10, (new Terminal)->getWidth() - $this->paddingLeft);
$lines = (new StyledConsoleTextWrapper)->wrap($this->content, $width);

foreach ($lines as $line) {
$style->writeln($padding.$line);
}
}
}
Loading