jbboehr/laravel-blade-linter

View on GitHub
src/BladeLinterCommand.php

Summary

Maintainability
A
2 hrs
Test Coverage
C
73%
<?php
declare(strict_types=1);

namespace Bdelespierre\LaravelBladeLinter;

use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Config;
use PhpParser\ParserFactory;

final class BladeLinterCommand extends Command
{
    protected $signature = 'blade:lint'
        . ' {--backend=*auto : Any of: auto, cli, eval, ext-ast, php-parser}'
        . ' {--fast}'
        . ' {--codeclimate=false : One of: stdout, stderr, false, or a FILE to open}'
        . ' {path?*}';

    protected $description = 'Checks Blade template syntax';

    public function handle(): int
    {
        $backends = $this->prepareBackends();
        $codeclimate = $this->getCodeClimateOutput();
        $allErrors = [];
        $nScanned = 0;

        if ($this->getOutput()->isVerbose()) {
            $this->info('blade-lint: Using backends: ' . join(', ', array_map(fn (Backend $backend) => $backend->name(), $backends)));
        }

        foreach ($this->getBladeFiles() as $file) {
            $errors = $this->checkFile($file, ...$backends);
            if (count($errors) > 0) {
                $status = self::FAILURE;
                foreach ($errors as $error) {
                    $this->error($error->toString());
                }
            } elseif ($this->getOutput()->isVerbose()) {
                $this->line("No syntax errors detected in {$file->getPathname()}");
            }

            $allErrors = array_merge($allErrors, $errors);
            $nScanned++;
        }

        if ($codeclimate !== null) {
            fwrite($codeclimate, json_encode(
                array_map(function (ErrorRecord $error) {
                    return [
                        'type' => 'issue',
                        'check_name' => 'Laravel Blade Lint',
                        'description' => $error->message,
                        'fingerprint' => md5(join("|", [$error->message, $error->path, $error->line])),
                        'categories' => ['Bug Risk'],
                        'location' => [
                            'path' => $error->path,
                            'lines' => [
                                'begin' => $error->line,
                            ],
                        ],
                        'severity' => 'blocker'
                    ];
                }, $allErrors),
                JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
            ));
        }

        $this->info('blade-lint: scanned: ' . $nScanned . ' files');

        return $status ?? self::SUCCESS;
    }

    /**
     * @return \Generator<\SplFileInfo>
     */
    protected function getBladeFiles(): \Generator
    {
        $paths = Arr::wrap($this->argument('path') ?: Config::get('view.paths'));

        foreach ($paths as $path) {
            if (is_file($path)) {
                yield new \SplFileInfo($path);
                continue;
            }

            $it = new \RecursiveDirectoryIterator($path);
            $it = new \RecursiveIteratorIterator($it);
            /** @var \RegexIterator<never, \SplFileInfo, \RecursiveIteratorIterator<\RecursiveDirectoryIterator>> $it */
            $it = new \RegexIterator($it, '/\.blade\.php$/', \RegexIterator::MATCH);

            yield from $it;
        }
    }

    /**
     * @return list<ErrorRecord>
     */
    private function checkFile(\SplFileInfo $file, Backend ...$backends): array
    {
        $code = file_get_contents($file->getPathname());

        if ($code === false) {
            throw new \RuntimeException('Failed to open file ' . $file->getPathname());
        }

        // compile the file and send it to the linter process
        $compiled = Blade::compileString($code);

        $errors = [];

        foreach ($backends as $backend) {
            $errors = array_merge(
                $errors,
                $backend->analyze($file, $compiled)
            );
        }

        return $errors;
    }

    /**
     * @return Backend[]
     */
    private function prepareBackends(): array
    {
        return array_map(function ($backendSpec) {
            switch ($backendSpec) {
                default: // case 'auto':
                    $fast = (bool)$this->option('fast');
                    if ($fast && extension_loaded('ast')) {
                        goto ext_ast;
                    } elseif ($fast && class_exists(ParserFactory::class)) {
                        goto php_parser;
                    }
                    goto cli;

                case 'cli':
                    cli:
                    return new Backend\Cli();

                case 'eval':
                    return new Backend\Evaluate();

                case 'ext-ast':
                    ext_ast:
                    return new Backend\ExtAst();

                case 'php-parser':
                    php_parser:
                    return new Backend\PhpParser();
            }
        }, (array) $this->option('backend'));
    }

    /**
     * @return ?resource
     */
    private function getCodeClimateOutput(): mixed
    {
        $codeclimate = $this->option('codeclimate') ?: 'stderr';
        if ($codeclimate === true || is_array($codeclimate)) {
            $codeclimate = 'stderr';
        }
        return match ($codeclimate) {
            'false' => null,
            'stderr' => STDERR,
            'stdout' => STDOUT,
            default => fopen($codeclimate, 'w') ?: null,
        };
    }
}