Trong bài viết này, mình sẽ hướng dẫn các bạn kiểm tra coding standard, các lỗi cú pháp trước khi commit lên git repository.

Tại sao việc này là cần thiết?

Các bạn có đang code theo chuẩn PSR-2 coding standard https://www.php-fig.org/psr/psr-2? Nếu các bạn đang code theo chuẩn này, luôn muốn code sạch đẹp mà có 1 thành viên trong team code ẩu, không tuân theo chuẩn này thì làm sao để ràng buộc? Mình luôn dùng Codeship để chạy unit tests và kiểm tra coding standard sau khi commit được push lên git repository nhưng Codeship chỉ được miễn phí 100 builds đầu tiên, nhiều hơn thì bạn phải trả phí. Vậy nên mình đã tìm cách để chạy test Psr2 trước khi commit với package https://github.com/squizlabs/PHP_CodeSniffer. Bên cạnh đó mình cũng sử dụng package https://github.com/JakubOnderka/PHP-Parallel-Lint để kiểm tra lỗi cú pháp PHP.

Đầu tiên, các bạn cần thêm 2 thư viện này vào phần required-dev trong composer.json và chạy composer update.

"jakub-onderka/php-parallel-lint": "dev-master",
"squizlabs/php_codesniffer": "^3.3"

Tiếp theo, hãy tạo file app/Console/Commands/PreCommitHook.php, class này sẽ nhằm mục đích kiểm tra code của bạn khi chạy command php artisan git:pre-commit.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Str;
use JakubOnderka\PhpParallelLint\ConsoleWriter;
use JakubOnderka\PhpParallelLint\TextOutputColored;
use RuntimeException;

class PreCommitHook extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'git:pre-commit-hook';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Hook before commit GIT';

    /**
     * Execute the console command.
     *
     * @return mixed
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
     */
    public function handle()
    {
        $changed = $this->getChangedPhpFiles();

        $output = new TextOutputColored(new ConsoleWriter);

        if (empty($changed)) {
            $output->writeLine('Success: Nothing to check!', TextOutputColored::TYPE_OK);
            return false;
        }

        $this->info('Running PHP lint...');
        if (!$this->lint($changed)) {
            exit($this->fails());
        }

        $this->info('Checking PSR-2 Coding Standard...');
        $start = now();
        if (!$this->checkPsr2($changed)) {
            exit($this->fails());
        }
        $end = now();
        $this->output->writeln('Checked ' . count($changed) . ' file(s) in ' . $end->diffInRealSeconds($start) . ' second(s)');

        $output->writeLine('Your code is perfect, no syntax error found!', TextOutputColored::TYPE_OK);

        return true;
    }

    /**
     * Get a list of changed PHP files
     *
     * @return array
     */
    protected function getChangedPhpFiles(): array
    {
        $changed = [];

        foreach ($this->getChangedFiles() as $path) {
            if (Str::endsWith($path, '.php') && !Str::endsWith($path, '.blade.php')) {
                $changed[] = $path;
            }
        }

        return $changed;
    }

    /**
     * Get a list of changed files
     *
     * @return array
     */
    protected function getChangedFiles(): array
    {
        if (!$this->exec($cmd = 'git status --short', $output)) {
            throw new RuntimeException('Unable to run command: ' . $cmd);
        }

        $changed = [];

        foreach ($output as $line) {
            if ($path = $this->parseGitStatus($line)) {
                $changed[] = $path;
            }
        }

        return $changed;
    }

    /**
     * Execute the command, return true if status is success, false otherwise
     *
     * @param string $command
     * @param array &$output
     * @param int &$status
     * @return bool
     */
    protected function exec(string $command, &$output = null, &$status = null): bool
    {
        exec($command, $output, $status);

        return $status == 0;
    }

    /**
     * Parses the git status line and return the changed file or null if the
     * file hasn't changed.
     *
     * @param string $line
     * @return string|null
     */
    protected function parseGitStatus(string $line): ?string
    {
        if (!preg_match('/^(.)(.)\s(\S+)(\s->\S+)?$/', $line, $matches)) {
            return null; // ignore incorrect lines
        }

        list(, $first, $second, $path) = $matches;

        if (!in_array($first, ['M', 'A'])) {
            return null;
        }

        return $path;
    }

    /**
     * Lint the given files (using JakubOnderka/PHP-Parallel-Lint)
     * @see https://github.com/JakubOnderka/PHP-Parallel-Lint
     *
     * @param array $changed
     * @return boolean
     */
    protected function lint(array $changed): bool
    {
        $process = $this->openParallelLintProcess($pipes);

        foreach ($changed as $path) {
            fwrite($pipes[0], $path . "\n");
        }

        fclose($pipes[0]);

        if (false === $output = stream_get_contents($pipes[1])) {
            throw new RuntimeException('Unable to get the lint result');
        }

        if (!$this->option('quiet') && trim($output)) {
            $this->output->writeln(trim($output));
        }

        fclose($pipes[1]);
        fclose($pipes[2]);

        return proc_close($process) == 0;
    }

    /**
     * Opens the parallel-lint program as a process and return the resource
     * (the pipes can be obtained as an out-argument).
     *
     * @param array &$pipes
     * @return resource
     */
    protected function openParallelLintProcess(&$pipes = null)
    {
        $options = [
            '--stdin',
            '--no-progress',
        ];

        if (!$this->option('no-ansi')) {
            $options[] = '--colors';
        }

        $cmd = base_path('vendor/bin/parallel-lint') . ' ' . implode(' ', $options);

        return $this->openProcess($cmd, $pipes);
    }

    /**
     * Open a process and give the pipes to stdin, stdout, stderr in $pipes
     * out-parameter. Returns the opened process as a resource.
     *
     * @param string $cmd
     * @param array &$pipes
     * @return resource
     */
    protected function openProcess(string $cmd, &$pipes = null)
    {
        $descriptionOrSpec = [
            0 => ['pipe', 'r'],  // stdin is a pipe that the child will read from
            1 => ['pipe', 'w'],  // stdout is a pipe that the child will write to
            2 => ['pipe', 'w'],  // stderr is a pipe that the child will write to
        ];

        return proc_open($cmd, $descriptionOrSpec, $pipes);
    }

    /**
     * Command failed message, returns 1
     *
     * @return int
     */
    protected function fails()
    {
        $message = 'Commit aborted: you have errors in your code!';

        if ($this->exec('which cowsay')) {
            $this->exec('cowsay -f unipony-smaller "{$message}"', $output);
            $message = implode("\n", $output);
        }

        $this->output->writeln('<fg=red>' . $message . '</fg=red>');

        return 1;
    }

    /**
     * Checks the PSR-2 compliance of changed files
     *
     * @param array $changed
     * @return boolean
     */
    protected function checkPsr2(array $changed): bool
    {
        $ignored = [
            '*/database/*',
            '*/public/*',
            '*/assets/*',
            '*/vendor/*',
        ];

        $options = [
            '--standard=' . base_path('phpcs.xml'),
            '--ignore=' . implode(',', $ignored),
        ];

        if (!$this->option('no-ansi')) {
            $options[] = '--colors';
        }

        $cmd = base_path('vendor/bin/phpcs') . ' ' . implode(' ', $options) . ' ' . implode(' ', $changed);

        $status = $this->exec($cmd, $output);

        if (!$this->option('quiet') && $output) {
            $this->output->writeln(implode("\n", $output));
        }

        return $status;
    }
}

 

Class này khá là dài, mình sẽ giải thích sơ qua 1 chút class này sẽ làm gì. Đầu tiên, nó sẽ lấy tất cả các files có sự thay đổi trong git, nghĩa là các files mà các bạn đã thêm vào git commit bằng lệnh git add. Những file này là file có đuôi .php trừ .blade.php. Sau đó nó sẽ sử dụng vendor/bin/parallel-lint để kiểm tra code linter và vendor/bin/phpcs để kiểm tra coding standard.

Đăng ký command vào app\Console\Kernel.php.

protected $commands = [
   \App\Console\Commands\PrecommitHook::class,
];

 

Tiếp theo, hãy tạo file phpcs.xml tại thư mục gốc project của bạn.

<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="pcsg-generated-ruleset">
    <description>Created with the PHP Coding Standard Generator. http://edorian.github.com/php-coding-standard-generator/
    </description>
    <rule ref="Generic.Classes.DuplicateClassName"/>
    <rule ref="Generic.CodeAnalysis.EmptyStatement"/>
    <rule ref="Generic.CodeAnalysis.ForLoopShouldBeWhileLoop"/>
    <rule ref="Generic.CodeAnalysis.JumbledIncrementer"/>
    <rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
    <rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
    <rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
    <rule ref="Generic.Commenting.Todo"/>
    <rule ref="Generic.Commenting.Fixme"/>
    <rule ref="Generic.ControlStructures.InlineControlStructure"/>
    <rule ref="Generic.Files.ByteOrderMark"/>
    <rule ref="Generic.Files.LineEndings"/>
    <rule ref="Generic.Files.OneClassPerFile"/>
    <rule ref="Generic.Formatting.DisallowMultipleStatements"/>
    <rule ref="Generic.Functions.CallTimePassByReference"/>
    <rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
    <rule ref="Generic.NamingConventions.ConstructorName"/>
    <rule ref="Generic.NamingConventions.UpperCaseConstantName"/>
    <rule ref="Generic.PHP.DeprecatedFunctions"/>
    <rule ref="Generic.PHP.DisallowShortOpenTag"/>
    <rule ref="Generic.PHP.ForbiddenFunctions"/>
    <rule ref="Generic.PHP.LowerCaseConstant"/>
    <rule ref="Generic.PHP.NoSilencedErrors"/>
    <rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
    <rule ref="Generic.WhiteSpace.ScopeIndent"/>
    <rule ref="MySource.PHP.EvalObjectFactory"/>
    <rule ref="PSR1.Classes.ClassDeclaration"/>
    <rule ref="PSR1.Files.SideEffects"/>
    <rule ref="PSR2.Classes.ClassDeclaration"/>
    <rule ref="PSR2.Classes.PropertyDeclaration"/>
    <rule ref="PSR2.ControlStructures.ControlStructureSpacing"/>
    <rule ref="PSR2.ControlStructures.ElseIfDeclaration"/>
    <rule ref="PSR2.ControlStructures.SwitchDeclaration"/>
    <rule ref="PSR2.Methods.MethodDeclaration"/>
    <rule ref="PSR2.Namespaces.NamespaceDeclaration"/>
    <rule ref="PSR2.Namespaces.UseDeclaration"/>
    <rule ref="Squiz.PHP.DiscouragedFunctions"/>
    <rule ref="Squiz.PHP.EmbeddedPhp"/>
    <rule ref="Squiz.PHP.Eval"/>
    <rule ref="Squiz.PHP.GlobalKeyword"/>
    <rule ref="Squiz.PHP.InnerFunctions"/>
    <rule ref="Squiz.PHP.LowercasePHPFunctions"/>
    <rule ref="Squiz.Strings.DoubleQuoteUsage"/>
    <rule ref="Squiz.PHP.NonExecutableCode"/>
    <rule ref="Squiz.Scope.StaticThisUsage"/>
    <rule ref="Squiz.WhiteSpace.ScopeKeywordSpacing"/>
    <rule ref="Zend.Files.ClosingTag"/>
</ruleset>

Đây là cấu hình cơ bản mình sử dung, mình chỉ check 1 số lỗi cơ bản thôi chứ không kiểm tra hết 😀

Sau đó thử thay đổi source code trong thư mục app và chạy kiểm tra nào.

git add --all
php artisan git:pre-commit

Nếu có lỗi nó sẽ hiện ra thế này (lỗi này mình tạo ra để test chứ không phải mặc định bị lỗi đó nhé :D)

 

Các bạn sẽ cần mở file báo lỗi và sửa lỗi đó và chạy lại.

Vậy là code đã đúng chuẩn rồi 😀

Tiếp theo, làm thế nào để ràng buộc code sẽ được kiểm tra trước khi commit lên git repository?

Ở đây mình sẽ sử dụng hook pre-commit của Git https://git-scm.com/docs/githooks#_pre_commit để chạy lệnh php artisan git:pre-commit.

Chúng ta sẽ tạo 1 command mới để đăng ký lệnh php artisan git:pre-commit vào git hooks. File đăng ký hook cho pre-commit sẽ nằm trong .git/hooks/pre-commit. Chúng ta sẽ tạo class app/Console/Commands/InstallHooks.php để đăng ký hook vào git hooks.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
use ReflectionClass;
use RuntimeException;

class InstallHooks extends Command
{
    use ConfirmableTrait;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'git:install-hooks';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Install GIT hooks';

    /**
     * @var array 
     */
    protected $hooks = [
        'pre-commit' => PreCommitHook::class,
    ];

    /**
     * Execute the console command.
     *
     * @return mixed
     * @throws \ReflectionException
     */
    public function handle()
    {
        if (!app()->isLocal()) {
            return false;
        }

        foreach ($this->hooks as $hook => $command) {
            $this->installHook($hook, $command)
                ? $this->info('Hook ' . $hook . ' successfully installed')
                : $this->error('Unable to install ' . $hook . ' hook');
        }


        return true;
    }

    /**
     * Install the hook command
     *
     * @param string $class
     * @return boolean
     * @throws \ReflectionException
     */
    protected function installHook(string $hook, string $class): bool
    {
        $signature = $this->getCommandSignature($class);
        $script = $this->getHookScript($signature);
        $path = base_path('.git/hooks/' . $hook);

        if (file_exists($path) && md5_file($path) != md5($script)) {
            if (!$this->confirmToProceed($path . ' already exists, do you want to overwrite it?', true)) {
                return false;
            }
        }

        return $this->writeHookScript($path, $script);
    }

    /**
     * Get the given command's class signature (e.g. git:pre-commit-hook)
     *
     * @param string $class
     * @return string
     * @throws \ReflectionException
     */
    protected function getCommandSignature(string $class): string
    {
        $reflect = new ReflectionClass($class);
        $properties = $reflect->getDefaultProperties();

        if (!preg_match('/^(\S+)/', $properties['signature'], $matches)) {
            throw new RuntimeException('Cannot read signature of ' . $class);
        }

        list(, $signature) = $matches;

        return $signature;
    }

    /**
     * Get the hook script content
     *
     * @param string $signature
     * @return string
     */
    protected function getHookScript(string $signature): string
    {
        $artisan = base_path('artisan');

        return "#!/bin/sh\n/usr/bin/env php " . $artisan . ' ' . $signature . "\n";
    }

    /**
     * Writes the git hook script file and return true on success, false otherwise
     *
     * @param string $path
     * @param string $script
     * @return boolean
     */
    protected function writeHookScript(string $path, string $script): bool
    {
        if (!$result = file_put_contents($path, $script)) {
            return false;
        }

        // read + write for owner, execute for everyone
        if (!chmod($path, 0755)) {
            return false;
        }

        return true;
    }
}

 

Tiếp theo, đăng ký command vào app/Console/Kernel.php.

protected $commands = [
    \App\Console\Commands\PrecommitHook::class,
    \App\Console\Commands\InstallHooks::class,
];

Sau đó, hãy chạy lệnh php artisan git:install-hooks để cài đặt hook vào git hooks.

Macintosh:cms macintosh$ sudo php artisan git:install-hooks
Password:
Hook pre-commit successfully installed

 

Đôi khi các bạn cần sudo để ghi vào thư mục .git/.

Tiếp theo bây giờ hãy thử commit xem sao nào 😀

 

Vậy là đã thành công rồi đó, bạn sẽ buộc phải sửa hết lỗi coding standard trước khi commit, điều này sẽ buộc các thành viên trong team của bạn phải tuân thủ chuẩn code được cấu hình trong phpcs.xml.

Bài viết khá dài dòng vì nhiều code và có thể khó hiểu với nhiều người. Vì vậy, nếu gặp khó khăn gì hãy để lại comment bên dưới nhé 😀

Update: Vì anh em đa số lười code nên mình đã đóng gói thành package sẵn, chỉ cần require và dùng: https://github.com/botble/git-commit-checker

Ai xài thì để lại 1 star để mình có động lực phát triển tiếp package này nhé 😀

 

 

Mình là 1 developer mới vào nghề, chưa có nhiều kinh nghiệm với lập trình web nhưng luôn muốn chia sẻ những hiểu biết của mình với các lập trình viên khác. Khá là gà và lười viết blog, chỉ ham code và chuyên Laravel.