├── tests
├── views
│ ├── invalid.blade.php
│ ├── invalid-expression.blade.php
│ ├── valid.blade.php
│ └── invalid-phpstan.blade.php
└── BladeLinterCommandTest.php
├── config
└── config.php
├── src
├── Backend.php
├── BladeLinterServiceProvider.php
├── ErrorRecord.php
├── Backend
│ ├── Evaluate.php
│ ├── PhpParser.php
│ ├── ExtAst.php
│ └── Cli.php
└── BladeLinterCommand.php
├── .phpcs.xml.dist
├── phpunit.xml.dist
├── LICENSE.md
├── bin
└── blade-linter
├── README.md
└── composer.json
/tests/views/invalid.blade.php:
--------------------------------------------------------------------------------
1 | @if()
2 | @section()
3 | @push()
4 | @yield()
5 |
--------------------------------------------------------------------------------
/tests/views/invalid-expression.blade.php:
--------------------------------------------------------------------------------
1 | @extends('layout')
2 |
3 | @section("body")
4 | @php(isset($a = 1))
5 | @endsection
6 |
--------------------------------------------------------------------------------
/config/config.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | public function analyze(\SplFileInfo $file, string $code): array;
14 |
15 | public static function name(): string;
16 | }
17 |
--------------------------------------------------------------------------------
/src/BladeLinterServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->runningInConsole()) {
13 | $this->commands([BladeLinterCommand::class]);
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/ErrorRecord.php:
--------------------------------------------------------------------------------
1 | message} in {$this->path} on line {$this->line}";
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 | config
4 | src
5 | tests
6 |
7 | */.direnv/*
8 | */tests/views/*
9 | */vendor/*
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/Backend/Evaluate.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | public function analyze(\SplFileInfo $file, string $code): array
17 | {
18 | $code = 'function() { ?>' . $code . 'getMessage(),
26 | $file->getPathname(),
27 | $e->getLine()
28 | )
29 | ];
30 | }
31 |
32 | return [];
33 | }
34 |
35 | public static function name(): string
36 | {
37 | return 'eval';
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 | src/
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | tests
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/Backend/PhpParser.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | public function analyze(\SplFileInfo $file, string $code): array
19 | {
20 | $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
21 | try {
22 | $parser->parse($code);
23 | } catch (Error $e) {
24 | return [
25 | new ErrorRecord(
26 | 'Parse error: ' . $e->getRawMessage(),
27 | $file->getPathname(),
28 | $e->getStartLine()
29 | )
30 | ];
31 | }
32 |
33 | return [];
34 | }
35 |
36 | public static function name(): string
37 | {
38 | return 'php-parser';
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Backend/ExtAst.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | public function analyze(\SplFileInfo $file, string $code): array
23 | {
24 | try {
25 | parse_code($code, $this->astVersion);
26 | } catch (\ParseError $e) {
27 | return [
28 | new ErrorRecord(
29 | 'Parse error: ' . $e->getMessage(),
30 | $file->getPathname(),
31 | $e->getLine()
32 | )
33 | ];
34 | }
35 |
36 | return [];
37 | }
38 |
39 | public static function name(): string
40 | {
41 | return 'ext-ast';
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | =====================
3 |
4 | Copyright © Benjamin Delespierre, John Boehr, and contributors
5 |
6 | Permission is hereby granted, free of charge, to any person
7 | obtaining a copy of this software and associated documentation
8 | files (the “Software”), to deal in the Software without
9 | restriction, including without limitation the rights to use,
10 | copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the
12 | Software is furnished to do so, subject to the following
13 | conditions:
14 |
15 | The above copyright notice and this permission notice shall be
16 | included in all copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25 | OTHER DEALINGS IN THE SOFTWARE.
26 |
--------------------------------------------------------------------------------
/bin/blade-linter:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | useAppPath($basePath);
17 | $app['config'] = fn() => new Config([
18 | // use current directory as view path
19 | 'view.paths' => [ $basePath ],
20 |
21 | // send compiled views to /tmp
22 | 'view.compiled' => sys_get_temp_dir(),
23 | ]);
24 |
25 | // register services
26 | (new FilesystemServiceProvider($app))->register();
27 | (new ViewServiceProvider($app))->register();
28 |
29 | // set the container so the Config::get calls resolve
30 | Facade::setFacadeApplication($app);
31 |
32 | // prepare the command
33 | $command = new BladeLinterCommand();
34 | $command->setLaravel($app);
35 | $command->setName('lint');
36 |
37 | // create the Symfony console application
38 | $application = new Application();
39 | $application->add($command);
40 | $application->run();
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Blade Linter
2 |
3 | [](https://packagist.org/packages/jbboehr/laravel-blade-linter)
4 | [](https://github.com/jbboehr/laravel-blade-linter/actions/workflows/ci.yml)
5 | [](https://codeclimate.com/github/jbboehr/laravel-blade-linter/test_coverage)
6 | [](https://codeclimate.com/github/jbboehr/laravel-blade-linter/maintainability)
7 | [](LICENSE.md)
8 |
9 | Performs syntax-checks of your Blade templates. Just that.
10 |
11 | ## Installation
12 |
13 | You can install the package via composer:
14 |
15 | ```bash
16 | composer require --dev bdelespierre/laravel-blade-linter
17 | ```
18 |
19 | ## Usage
20 |
21 | ```bash
22 | php artisan blade:lint
23 | ```
24 |
25 | Or if you want to lint specific templates or directories:
26 |
27 | ```bash
28 | php artisan blade:lint resources/views/
29 | ```
30 |
31 | ### Testing
32 |
33 | ``` bash
34 | composer test
35 | ```
36 |
37 | ## Credits
38 |
39 | - [All Contributors](./graphs/contributors)
40 |
41 | ## License
42 |
43 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
44 |
--------------------------------------------------------------------------------
/src/Backend/Cli.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | public function analyze(\SplFileInfo $file, string $code): array
20 | {
21 | $output = '';
22 | $message = '';
23 | $result = $this->lint($code, $output, $message);
24 |
25 | if (!$result) {
26 | $line = null;
27 | if (false !== preg_match(self::REGEX, trim($message), $matches)) {
28 | $line = isset($matches[1]) ? (int) $matches[1] : null;
29 | $message = preg_replace(self::REGEX, '', $message) ?? '';
30 | }
31 |
32 | return [
33 | new ErrorRecord(
34 | trim($message),
35 | $file->getPathname(),
36 | $line
37 | ),
38 | ];
39 | }
40 |
41 | return [];
42 | }
43 |
44 | public static function name(): string
45 | {
46 | return 'cli';
47 | }
48 |
49 | private function lint(string $code, string &$stdout = "", string &$stderr = ""): bool
50 | {
51 | $descriptors = [
52 | 0 => ["pipe", "r"], // read from stdin
53 | 1 => ["pipe", "w"], // write to stdout
54 | 2 => ["pipe", "w"], // write to stderr
55 | ];
56 |
57 | // open linter process (php -l)
58 | $process = proc_open('php -d display_errors=stderr -l', $descriptors, $pipes);
59 |
60 | if (!is_resource($process)) {
61 | throw new \RuntimeException("unable to open process 'php -l'");
62 | }
63 |
64 | fwrite($pipes[0], $code);
65 | fclose($pipes[0]);
66 |
67 | $stdout = stream_get_contents($pipes[1]) ?: '';
68 | fclose($pipes[1]);
69 |
70 | $stderr = stream_get_contents($pipes[2]) ?: '';
71 | fclose($pipes[2]);
72 |
73 | // it is important that you close any pipes before calling
74 | // proc_close in order to avoid a deadlock
75 | $retval = proc_close($process);
76 |
77 | // zero actually means "no error"
78 | return $retval === 0;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bdelespierre/laravel-blade-linter",
3 | "description": "A simple Blade template syntax checker for Laravel",
4 | "keywords": [
5 | "laravel-blade-linter",
6 | "laravel",
7 | "blade",
8 | "linter"
9 | ],
10 | "homepage": "https://github.com/bdelespierre/laravel-blade-linter",
11 | "license": "MIT",
12 | "type": "library",
13 | "authors": [
14 | {
15 | "name": "Benjamin Delespierre",
16 | "email": "benjamin.delespierre@gmail.com"
17 | },
18 | {
19 | "name": "John Boehr",
20 | "email": "jbboehr@gmail.com"
21 | }
22 | ],
23 | "require": {
24 | "php": "^8.0",
25 | "illuminate/support": "^9.0 || ^10.0"
26 | },
27 | "require-dev": {
28 | "laravel/framework": "^9.0 || ^10.0",
29 | "nikic/php-parser": "^4.15",
30 | "nunomaduro/larastan": "^2.0",
31 | "orchestra/testbench": "^7.22.0",
32 | "phpstan/phpstan": "^1.9",
33 | "phpstan/phpstan-phpunit": "^1.3",
34 | "phpstan/phpstan-strict-rules": "^1.4",
35 | "phpunit/phpunit": "^9.3",
36 | "squizlabs/php_codesniffer": "^3.7"
37 | },
38 | "suggest": {
39 | "ext-ast": "*",
40 | "nikic/php-parser": "*"
41 | },
42 | "bin": [
43 | "bin/blade-linter"
44 | ],
45 | "autoload": {
46 | "psr-4": {
47 | "Bdelespierre\\LaravelBladeLinter\\": "src",
48 | "Bdelespierre\\LaravelBladeLinter\\FakeApp\\": "."
49 | }
50 | },
51 | "autoload-dev": {
52 | "Tests\\": "tests/"
53 | },
54 | "config": {
55 | "sort-packages": true,
56 | "allow-plugins": {
57 | "drupol/composer-plugin-nixify": true
58 | }
59 | },
60 | "extra": {
61 | "laravel": {
62 | "providers": [
63 | "Bdelespierre\\LaravelBladeLinter\\BladeLinterServiceProvider"
64 | ],
65 | "aliases": {
66 | "LaravelBladeLinter": "Bdelespierre\\LaravelBladeLinter\\BladeLinterFacade"
67 | }
68 | },
69 | "branch-alias": {
70 | "dev-master": "1.0.x-dev",
71 | "dev-develop": "1.0.x-dev"
72 | }
73 | },
74 | "scripts": {
75 | "test": [
76 | "vendor/bin/phpunit --color=always"
77 | ],
78 | "test:coverage": [
79 | "@putenv XDEBUG_MODE=coverage",
80 | "vendor/bin/phpunit --color=always --coverage-clover=\"build/coverage/clover.xml\""
81 | ],
82 | "test:coverage-html": [
83 | "@putenv XDEBUG_MODE=coverage",
84 | "vendor/bin/phpunit --color=always --coverage-html=\"build/coverage\""
85 | ],
86 | "build": [
87 | "rm -rfv vendor/ *.phar && composer install --no-dev && phar-composer build ./"
88 | ]
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/tests/BladeLinterCommandTest.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | protected function getPackageProviders($app): array
18 | {
19 | return [
20 | BladeLinterServiceProvider::class
21 | ];
22 | }
23 |
24 | /**
25 | * @param Application $app
26 | */
27 | protected function getEnvironmentSetUp($app): void
28 | {
29 | $app->make('config')->set('view.paths', [
30 | __DIR__ . '/views',
31 | ]);
32 | }
33 |
34 | /**
35 | * @dataProvider backendProvider
36 | */
37 | public function testValidBladeFilePass(string $backend): void
38 | {
39 | $path = __DIR__ . '/views/valid.blade.php';
40 | $exit = Artisan::call('blade:lint', ['-v' => true, '--backend' => $backend, 'path' => $path]);
41 | $output = trim(Artisan::output());
42 |
43 | $this->assertEquals(
44 | 0,
45 | $exit,
46 | "Validating a valid template should exit with an 'OK' status"
47 | );
48 |
49 | $this->assertStringContainsString(
50 | "No syntax errors detected in {$path}",
51 | $output,
52 | "Validating a valid template should display the validation message"
53 | );
54 |
55 | if ($backend !== 'auto') {
56 | $this->assertStringContainsString($backend, $output);
57 | }
58 | }
59 |
60 | /**
61 | * @dataProvider backendProvider
62 | */
63 | public function testInvalidBladeFilePass(string $backend): void
64 | {
65 | $path = __DIR__ . '/views/invalid.blade.php';
66 | $exit = Artisan::call('blade:lint', ['-v' => true, '--backend' => $backend, 'path' => $path]);
67 |
68 | $this->assertEquals(
69 | 1,
70 | $exit,
71 | "Validating an invalid template should exit with a 'NOK' status"
72 | );
73 |
74 | $this->assertMatchesRegularExpression(
75 | "~Parse error: ?syntax error, unexpected .* in {$path} on line 1~i",
76 | trim(Artisan::output()),
77 | "Syntax error should be displayed"
78 | );
79 | }
80 |
81 | /**
82 | * @dataProvider backendProvider
83 | */
84 | public function testInvalidExpressionBladeFilePass(string $backend): void
85 | {
86 | if ($backend !== 'cli') {
87 | $this->markTestSkipped('This currently is an E_COMPILE_ERROR in PHP and not a parse error');
88 | }
89 |
90 | $path = __DIR__ . '/views/invalid-expression.blade.php';
91 | $exit = Artisan::call('blade:lint', ['-v' => true, '--backend' => $backend, 'path' => $path]);
92 |
93 | $this->assertEquals(
94 | 1,
95 | $exit,
96 | "Validating an invalid template should exit with a 'NOK' status"
97 | );
98 |
99 | $this->assertMatchesRegularExpression(
100 | "~Fatal error: Cannot use isset\(\) on the result of an expression .* in {$path} on line \d+~i",
101 | trim(Artisan::output()),
102 | "Syntax error should be displayed"
103 | );
104 | }
105 |
106 | /**
107 | * @dataProvider backendProvider
108 | */
109 | public function testWithoutPath(string $backend): void
110 | {
111 | if ($backend === 'eval') {
112 | $this->markTestSkipped('This currently is an E_COMPILE_ERROR in PHP and not a parse error');
113 | }
114 |
115 | $exit = Artisan::call('blade:lint', ['-v' => true, '--backend' => $backend]);
116 |
117 | $this->assertEquals(
118 | 1,
119 | $exit,
120 | "Validating an invalid template should exit with a 'NOK' status"
121 | );
122 |
123 | $output = Artisan::output();
124 |
125 | $this->assertMatchesRegularExpression(
126 | "~No syntax errors detected in .*/tests/views/invalid-phpstan\\.blade\\.php\n~i",
127 | $output,
128 | );
129 |
130 | $this->assertMatchesRegularExpression(
131 | "~Parse error: ?syntax error, unexpected .* in .*/tests/views/invalid\\.blade\\.php on line 1\n~i",
132 | $output,
133 | );
134 |
135 | $this->assertMatchesRegularExpression(
136 | "~No syntax errors detected in .*/tests/views/valid\\.blade\\.php\n~i",
137 | $output,
138 | );
139 | }
140 |
141 | /**
142 | * @dataProvider backendProvider
143 | */
144 | public function testWithMultiplePaths(string $backend): void
145 | {
146 | $path = [
147 | __DIR__ . '/views/valid.blade.php',
148 | __DIR__ . '/views/invalid.blade.php',
149 | ];
150 |
151 | $exit = Artisan::call('blade:lint', ['-v' => true, '--backend' => $backend, 'path' => $path]);
152 |
153 | $this->assertEquals(
154 | 1,
155 | $exit,
156 | "Validating an invalid template should exit with a 'NOK' status"
157 | );
158 |
159 | $output = trim(Artisan::output());
160 |
161 | $this->assertStringContainsString(
162 | "No syntax errors detected in {$path[0]}",
163 | $output,
164 | "Validating a valid template should display the validation message"
165 | );
166 |
167 | $this->assertMatchesRegularExpression(
168 | "~Parse error: ?syntax error, unexpected .* in {$path[1]} on line 1~i",
169 | $output,
170 | "Syntax error should be displayed"
171 | );
172 | }
173 |
174 | /**
175 | * @return list>
176 | */
177 | public function backendProvider(): array
178 | {
179 | return [
180 | ['auto'],
181 | ['cli'],
182 | ['eval'],
183 | ['ext-ast'],
184 | ['php-parser'],
185 | ];
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/src/BladeLinterCommand.php:
--------------------------------------------------------------------------------
1 | prepareBackends();
26 | $codeclimate = $this->getCodeClimateOutput();
27 | $allErrors = [];
28 | $nScanned = 0;
29 |
30 | if ($this->getOutput()->isVerbose()) {
31 | $this->info('blade-lint: Using backends: ' . join(', ', array_map(fn (Backend $backend) => $backend->name(), $backends)));
32 | }
33 |
34 | foreach ($this->getBladeFiles() as $file) {
35 | $errors = $this->checkFile($file, ...$backends);
36 | if (count($errors) > 0) {
37 | $status = self::FAILURE;
38 | foreach ($errors as $error) {
39 | $this->error($error->toString());
40 | }
41 | } elseif ($this->getOutput()->isVerbose()) {
42 | $this->line("No syntax errors detected in {$file->getPathname()}");
43 | }
44 |
45 | $allErrors = array_merge($allErrors, $errors);
46 | $nScanned++;
47 | }
48 |
49 | if ($codeclimate !== null) {
50 | fwrite($codeclimate, json_encode(
51 | array_map(function (ErrorRecord $error) {
52 | return [
53 | 'type' => 'issue',
54 | 'check_name' => 'Laravel Blade Lint',
55 | 'description' => $error->message,
56 | 'fingerprint' => md5(join("|", [$error->message, $error->path, $error->line])),
57 | 'categories' => ['Bug Risk'],
58 | 'location' => [
59 | 'path' => $error->path,
60 | 'lines' => [
61 | 'begin' => $error->line,
62 | ],
63 | ],
64 | 'severity' => 'blocker'
65 | ];
66 | }, $allErrors),
67 | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
68 | ));
69 | }
70 |
71 | $this->info('blade-lint: scanned: ' . $nScanned . ' files');
72 |
73 | return $status ?? self::SUCCESS;
74 | }
75 |
76 | /**
77 | * @return \Generator<\SplFileInfo>
78 | */
79 | protected function getBladeFiles(): \Generator
80 | {
81 | $paths = Arr::wrap($this->argument('path') ?: Config::get('view.paths'));
82 |
83 | foreach ($paths as $path) {
84 | if (is_file($path)) {
85 | yield new \SplFileInfo($path);
86 | continue;
87 | }
88 |
89 | $it = new \RecursiveDirectoryIterator($path);
90 | $it = new \RecursiveIteratorIterator($it);
91 | /** @var \RegexIterator> $it */
92 | $it = new \RegexIterator($it, '/\.blade\.php$/', \RegexIterator::MATCH);
93 |
94 | yield from $it;
95 | }
96 | }
97 |
98 | /**
99 | * @return list
100 | */
101 | private function checkFile(\SplFileInfo $file, Backend ...$backends): array
102 | {
103 | $code = file_get_contents($file->getPathname());
104 |
105 | if ($code === false) {
106 | throw new \RuntimeException('Failed to open file ' . $file->getPathname());
107 | }
108 |
109 | // compile the file and send it to the linter process
110 | $compiled = Blade::compileString($code);
111 |
112 | $errors = [];
113 |
114 | foreach ($backends as $backend) {
115 | $errors = array_merge(
116 | $errors,
117 | $backend->analyze($file, $compiled)
118 | );
119 | }
120 |
121 | return $errors;
122 | }
123 |
124 | /**
125 | * @return Backend[]
126 | */
127 | private function prepareBackends(): array
128 | {
129 | return array_map(function ($backendSpec) {
130 | switch ($backendSpec) {
131 | default: // case 'auto':
132 | $fast = $this->option('fast');
133 | if ($fast && extension_loaded('ast')) {
134 | goto ext_ast;
135 | } elseif ($fast && class_exists(ParserFactory::class)) {
136 | goto php_parser;
137 | }
138 | goto cli;
139 |
140 | case 'cli':
141 | cli:
142 | return new Backend\Cli();
143 |
144 | case 'eval':
145 | return new Backend\Evaluate();
146 |
147 | case 'ext-ast':
148 | ext_ast:
149 | return new Backend\ExtAst();
150 |
151 | case 'php-parser':
152 | php_parser:
153 | return new Backend\PhpParser();
154 | }
155 | /** @phpstan-ignore-next-line */
156 | }, (array) $this->option('backend'));
157 | }
158 |
159 | /**
160 | * @return ?resource
161 | */
162 | private function getCodeClimateOutput(): mixed
163 | {
164 | $codeclimate = $this->option('codeclimate') ?: 'stderr';
165 |
166 | /** @phpstan-ignore-next-line */
167 | if ($codeclimate === true || is_array($codeclimate)) {
168 | $codeclimate = 'stderr';
169 | }
170 |
171 | return match ($codeclimate) {
172 | 'false' => null,
173 | 'stderr' => STDERR,
174 | 'stdout' => STDOUT,
175 | default => fopen($codeclimate, 'w') ?: null,
176 | };
177 | }
178 | }
179 |
--------------------------------------------------------------------------------