├── .github
└── dependabot.yml
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── bin
└── twig-linter
├── composer.json
├── phpcs.xml.dist
├── phpunit.xml.dist
├── psalm.xml.dist
├── src
├── Command
│ └── LintCommand.php
└── StubEnvironment.php
└── tests
└── LintCommandTest.php
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: composer
5 | directory: "/"
6 | schedule:
7 | interval: daily
8 | time: "10:00"
9 | open-pull-requests-limit: 20
10 | ignore:
11 | - dependency-name: vimeo/psalm
12 | versions:
13 | - 4.4.0
14 | - 4.5.0
15 | - 4.6.0
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | composer.lock
3 | .phpunit.result.cache
4 |
5 | # Created by https://www.gitignore.io/api/vim
6 |
7 | ### Vim ###
8 | # Swap
9 | [._]*.s[a-v][a-z]
10 | [._]*.sw[a-p]
11 | [._]s[a-rt-v][a-z]
12 | [._]ss[a-gi-z]
13 | [._]sw[a-p]
14 |
15 | # Session
16 | Session.vim
17 |
18 | # Temporary
19 | .netrwhist
20 | *~
21 | # Auto-generated tag files
22 | tags
23 | # Persistent undo
24 | [._]*.un~
25 |
26 |
27 | # End of https://www.gitignore.io/api/vim
28 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 | dist: bionic
3 | sudo: false
4 |
5 | cache:
6 | directories:
7 | - $HOME/.composer/cache
8 | - vendor
9 |
10 | php:
11 | - 7.4 # EOL: 28 Nov 2022
12 | - 8.0 # EOL: 26 Nov 2023
13 |
14 | matrix:
15 | fast_finish: true
16 |
17 | install:
18 | - composer install --no-interaction --no-progress --prefer-dist --ansi
19 |
20 | script:
21 | - composer cs-check
22 | - composer static-analysis
23 | - composer test
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2004-2018 Fabien Potencier
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is furnished
8 | to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.com/sserbin/twig-linter)
2 |
3 | # Intro
4 | Standalone cli twig linter (heavily based on twig lint command from symfony-bridge), for those who don't use Symfony (if you do, you are better of using Symfony native `lint:twig`)
5 |
6 | # Installation
7 | ```
8 | composer require --dev sserbin/twig-linter:@dev
9 | ```
10 |
11 | # Usage
12 | ```
13 | vendor/bin/twig-linter lint /path/to/your/templates
14 | ```
15 | By default `*.twig` files are searched. Pass in `--ext=?` (e.g. `--ext=html`) to override it.
16 |
17 | # Limitations/known issues
18 | Any non-standard twig's functions/filters/tests are ignored during linting. I.e. if there's invocations of undefined filter this will *not* be reported by linter as it doesn't know about your specific twig environment.
19 |
20 | If, however, you want it to, you can manually add `LintCommand` to your console application's command set instantiating it with *your* environment.
21 |
--------------------------------------------------------------------------------
/bin/twig-linter:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | add($lintCommand);
34 | $app->setDefaultCommand('lint');
35 | $app->run();
36 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sserbin/twig-linter",
3 | "description": "Standalone cli twig linter (based on symfony-bridge-twig)",
4 | "keywords": ["twig", "lint", "linter"],
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "sserbin",
9 | "email": "sserbin@users.noreply.github.com"
10 | }
11 | ],
12 | "autoload": {
13 | "psr-4": { "Sserbin\\TwigLinter\\": "src/"}
14 | },
15 | "autoload-dev": {
16 | "psr-4": { "Sserbin\\TwigLinter\\Tests\\": "tests/"}
17 | },
18 | "require": {
19 | "composer-runtime-api": "^2.0",
20 | "php": "^7.4|^8.0",
21 | "symfony/console": "^5.4 || ^6.1",
22 | "symfony/finder": "^5.4 || ^6.1",
23 | "twig/twig": "^2.5 || ^3"
24 | },
25 | "require-dev": {
26 | "phpunit/phpunit": "^7.3||^8.2|^9.5",
27 | "squizlabs/php_codesniffer": "^3.3",
28 | "vimeo/psalm": "^4.7 || ^5.8"
29 | },
30 | "bin": ["bin/twig-linter"],
31 | "scripts": {
32 | "cs-check": "phpcs",
33 | "static-analysis" : "psalm",
34 | "test": "vendor/bin/phpunit --colors=always"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 | ./src
4 | ./tests
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 | tests
15 |
16 |
17 |
18 |
19 | src/
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/psalm.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/Command/LintCommand.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 | namespace Sserbin\TwigLinter\Command;
14 |
15 | use Symfony\Component\Console\Command\Command;
16 | use Symfony\Component\Console\Exception\InvalidArgumentException;
17 | use Symfony\Component\Console\Exception\RuntimeException;
18 | use Symfony\Component\Console\Input\InputArgument;
19 | use Symfony\Component\Console\Input\InputInterface;
20 | use Symfony\Component\Console\Input\InputOption;
21 | use Symfony\Component\Console\Output\OutputInterface;
22 | use Symfony\Component\Console\Style\SymfonyStyle;
23 | use Symfony\Component\Finder\Finder;
24 | use Symfony\Component\Finder\SplFileInfo;
25 | use Twig\Environment;
26 | use Twig\Error\Error;
27 | use Twig\Loader\ArrayLoader;
28 | use Twig\Source;
29 |
30 | /**
31 | * Command that will validate your template syntax and output encountered errors.
32 | *
33 | * @author Marc Weistroff
34 | * @author Jérôme Tamarelle
35 | */
36 | class LintCommand extends Command
37 | {
38 | /** @var ?string */
39 | protected static $defaultName = 'lint';
40 |
41 | /** @var Environment */
42 | private $twig;
43 |
44 | public function __construct(Environment $twig)
45 | {
46 | parent::__construct();
47 |
48 | $this->twig = $twig;
49 | }
50 |
51 | protected function configure(): void
52 | {
53 | $this
54 | ->setDescription('Lints a template and outputs encountered errors')
55 | ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt')
56 | ->addOption('ext', null, InputOption::VALUE_REQUIRED, 'Templates extension', 'twig')
57 | ->addArgument('filename', InputArgument::IS_ARRAY)
58 | ->setHelp(<<<'EOF'
59 | The %command.name% command lints a template and outputs to STDOUT
60 | the first encountered syntax error.
61 |
62 | You can validate the syntax of contents passed from STDIN:
63 |
64 | cat filename | php %command.full_name%
65 |
66 | Or the syntax of a file:
67 |
68 | php %command.full_name% filename
69 |
70 | Or of a whole directory:
71 |
72 | php %command.full_name% dirname
73 | php %command.full_name% dirname --format=json --ext=html
74 |
75 | EOF
76 | )
77 | ;
78 | }
79 |
80 | protected function execute(InputInterface $input, OutputInterface $output)
81 | {
82 | $io = new SymfonyStyle($input, $output);
83 | /** @var string[] */
84 | $filenames = $input->getArgument('filename');
85 |
86 | if (0 === count($filenames)) {
87 | if (0 !== ftell(STDIN)) {
88 | throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
89 | }
90 |
91 | $template = '';
92 | while (!feof(STDIN)) {
93 | $template .= fread(STDIN, 1024);
94 | }
95 |
96 | return $this->display($input, $output, $io, array($this->validate($template, uniqid('sf_', true))));
97 | }
98 |
99 | /** @var string */
100 | $templatesExt = $input->getOption('ext');
101 |
102 | $filesInfo = $this->getFilesInfo($filenames, $templatesExt);
103 |
104 | return $this->display($input, $output, $io, $filesInfo);
105 | }
106 |
107 | /**
108 | * @param string[] $filenames
109 | * @return array
110 | */
111 | private function getFilesInfo(array $filenames, string $ext): array
112 | {
113 | $filesInfo = [];
114 | foreach ($filenames as $filename) {
115 | foreach ($this->findFiles($filename, $ext) as $file) {
116 | $file = (string) $file;
117 | $filesInfo[] = $this->validate(file_get_contents($file), $file);
118 | }
119 | }
120 |
121 | return $filesInfo;
122 | }
123 |
124 | /**
125 | * @return iterable
126 | */
127 | protected function findFiles(string $filename, string $ext): iterable
128 | {
129 | if (is_file($filename)) {
130 | return [$filename];
131 | } elseif (is_dir($filename)) {
132 | /** @var iterable */
133 | return Finder::create()->files()->in($filename)->name('*.' . $ext);
134 | }
135 |
136 | throw new RuntimeException(sprintf('File or directory "%s" is not readable', $filename));
137 | }
138 |
139 | /**
140 | * @return array{template:string,file:string,valid:bool,exception?:Error,line?:int}
141 | */
142 | private function validate(string $template, string $file): array
143 | {
144 | $realLoader = $this->twig->getLoader();
145 | try {
146 | $temporaryLoader = new ArrayLoader(array($file => $template));
147 | $this->twig->setLoader($temporaryLoader);
148 | $nodeTree = $this->twig->parse($this->twig->tokenize(new Source($template, $file)));
149 | $this->twig->compile($nodeTree);
150 | $this->twig->setLoader($realLoader);
151 | } catch (Error $e) {
152 | $this->twig->setLoader($realLoader);
153 |
154 | return [
155 | 'template' => $template,
156 | 'file' => $file,
157 | 'line' => $e->getTemplateLine(),
158 | 'valid' => false,
159 | 'exception' => $e
160 | ];
161 | }
162 |
163 | return [
164 | 'template' => $template,
165 | 'file' => $file,
166 | 'valid' => true
167 | ];
168 | }
169 |
170 | /**
171 | * @param array $files
172 | */
173 | private function display(
174 | InputInterface $input,
175 | OutputInterface $output,
176 | SymfonyStyle $io,
177 | array $files
178 | ): int {
179 | $format = $input->getOption('format');
180 | assert(is_string($format));
181 | switch ($format) {
182 | case 'txt':
183 | return $this->displayTxt($output, $io, $files);
184 | case 'json':
185 | return $this->displayJson($output, $files);
186 | default:
187 | throw new InvalidArgumentException(sprintf(
188 | 'The format "%s" is not supported.',
189 | $format
190 | ));
191 | }
192 | }
193 |
194 | /**
195 | * @param array $filesInfo
196 | */
197 | private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo): int
198 | {
199 | $errors = 0;
200 |
201 | foreach ($filesInfo as $info) {
202 | if ($info['valid'] && $output->isVerbose()) {
203 | $io->comment('OK' . ($info['file'] ? sprintf(' in %s', $info['file']) : ''));
204 | } elseif (!$info['valid']) {
205 | ++$errors;
206 | assert(isset($info['exception']));
207 | $this->renderException($io, $info['template'], $info['exception'], $info['file']);
208 | }
209 | }
210 |
211 | if (0 === $errors) {
212 | $io->success(sprintf('All %d Twig files contain valid syntax.', count($filesInfo)));
213 | } else {
214 | $io->warning(sprintf(
215 | '%d Twig files have valid syntax and %d contain errors.',
216 | count($filesInfo) - $errors,
217 | $errors
218 | ));
219 | }
220 |
221 | return min($errors, 1);
222 | }
223 |
224 | /**
225 | * @param array $filesInfo
226 | */
227 | private function displayJson(OutputInterface $output, array $filesInfo): int
228 | {
229 | $errors = 0;
230 |
231 | foreach ($filesInfo as $k => $v) {
232 | $filesInfo[$k]['file'] = (string) $v['file'];
233 | unset($filesInfo[$k]['template']);
234 |
235 | if (!$v['valid']) {
236 | assert(isset($v['exception']));
237 | $filesInfo[$k]['message'] = $v['exception']->getMessage();
238 | unset($filesInfo[$k]['exception']);
239 | $errors++;
240 | }
241 | }
242 |
243 | $output->writeln(json_encode($filesInfo, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
244 |
245 | return min($errors, 1);
246 | }
247 |
248 | private function renderException(
249 | OutputInterface $output,
250 | string $template,
251 | Error $exception,
252 | ?string $file = null
253 | ): void {
254 | $line = $exception->getTemplateLine();
255 |
256 | if ($file) {
257 | /** @psalm-suppress UndefinedMethod */
258 | $output->text(sprintf(' ERROR in %s (line %s)', $file, $line));
259 | } else {
260 | /** @psalm-suppress UndefinedMethod */
261 | $output->text(sprintf(' ERROR (line %s)', $line));
262 | }
263 |
264 | foreach ($this->getContext($template, $line) as $lineNumber => $code) {
265 | /** @psalm-suppress UndefinedMethod */
266 | $output->text(sprintf(
267 | '%s %-6s %s',
268 | $lineNumber === $line ? ' >> ' : ' ',
269 | $lineNumber,
270 | $code
271 | ));
272 | if ($lineNumber === $line) {
273 | /** @psalm-suppress UndefinedMethod */
274 | $output->text(sprintf(' >> %s ', $exception->getRawMessage()));
275 | }
276 | }
277 | }
278 |
279 | /**
280 | * @return array
281 | */
282 | private function getContext(string $template, int $line, int $context = 3): array
283 | {
284 | $lines = explode("\n", $template);
285 |
286 | $position = max(0, $line - $context);
287 | $max = min(\count($lines), $line - 1 + $context);
288 |
289 | $result = array();
290 | while ($position < $max) {
291 | $result[$position + 1] = $lines[$position];
292 | ++$position;
293 | }
294 |
295 | return $result;
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/src/StubEnvironment.php:
--------------------------------------------------------------------------------
1 | noop(), [
33 | 'is_variadic' => true,
34 | ]);
35 | }
36 |
37 | /**
38 | * @param string $name
39 | * @psalm-suppress ImplementedReturnTypeMismatch
40 | */
41 | public function getFunction($name): ?TwigFunction
42 | {
43 | /**
44 | * @psalm-suppress InternalMethod
45 | */
46 | $defaultFunction = parent::getFunction($name);
47 |
48 | if ($defaultFunction) { // don't attempt to stub twig's builtin function
49 | return $defaultFunction;
50 | }
51 |
52 | return new TwigFunction((string)$name, $this->noop(), [
53 | 'is_variadic' => true,
54 | ]);
55 | }
56 |
57 | /**
58 | * @param string $name
59 | * @psalm-suppress ImplementedReturnTypeMismatch
60 | */
61 | public function getTest($name): ?TwigTest
62 | {
63 | /**
64 | * @var string[]
65 | * @psalm-suppress InternalMethod
66 | */
67 | $defaultTests = array_keys(parent::getTests());
68 | $isDefault = isset($defaultTests[$name]) || $this->listContainsSubstring($defaultTests, $name);
69 |
70 | if ($isDefault) { // don't attempt to stub twig's builtin test
71 | /** @psalm-suppress InternalMethod */
72 | $parentTest = parent::getTest($name);
73 |
74 | if ($parentTest instanceof TwigTest) {
75 | return $parentTest;
76 | }
77 |
78 | // In twig 2.x this can return `false`.
79 | // Lets just force it here as null because
80 | // of the added typehint for Twig 3.x
81 | return null;
82 | }
83 |
84 | return new TwigTest((string)$name, $this->noop(), [
85 | 'is_variadic' => true,
86 | ]);
87 | }
88 |
89 | private function noop(): callable
90 | {
91 | /**
92 | * @param mixed $_
93 | * @param array $arg
94 | * @psalm-suppress UnusedClosureParam
95 | */
96 | return function ($_ = null, array $arg = []): void {
97 | };
98 | }
99 |
100 | /**
101 | * @param string[] $list
102 | * @return bool
103 | */
104 | private function listContainsSubstring(array $list, string $needle): bool
105 | {
106 | foreach ($list as $item) {
107 | if (false !== strpos($item, $needle)) {
108 | return true;
109 | }
110 | }
111 | return false;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/tests/LintCommandTest.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 | namespace Sserbin\TwigLinter\Tests;
12 |
13 | use PHPUnit\Framework\TestCase;
14 | use RuntimeException;
15 | use Symfony\Component\Console\Application;
16 | use Symfony\Component\Console\Output\OutputInterface;
17 | use Symfony\Component\Console\Tester\CommandTester;
18 | use Twig\Environment;
19 | use Twig\Loader\FilesystemLoader;
20 | use Sserbin\TwigLinter\Command\LintCommand;
21 |
22 | class LintCommandTest extends TestCase
23 | {
24 | /** @var string[] */
25 | private $files;
26 |
27 | public function testLintCorrectFile(): void
28 | {
29 | $tester = $this->createCommandTester();
30 | $filename = $this->createFile('{{ foo }}');
31 |
32 | $ret = $tester->execute(
33 | ['filename' => [$filename]],
34 | ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]
35 | );
36 |
37 | $this->assertEquals(0, $ret, 'Returns 0 in case of success');
38 | $this->assertStringContainsString('OK in', trim($tester->getDisplay()));
39 | }
40 |
41 | public function testLintIncorrectFile(): void
42 | {
43 | $tester = $this->createCommandTester();
44 | $filename = $this->createFile('{{ foo');
45 |
46 | $ret = $tester->execute(['filename' => [$filename]], ['decorated' => false]);
47 |
48 | $this->assertEquals(1, $ret, 'Returns 1 in case of error');
49 | $this->assertRegExp('/ERROR in \S+ \(line /', trim($tester->getDisplay()));
50 | }
51 |
52 | public function testLintFileNotReadable(): void
53 | {
54 | $this->expectException(RuntimeException::class);
55 | $tester = $this->createCommandTester();
56 | $filename = $this->createFile('');
57 | unlink($filename);
58 |
59 | $ret = $tester->execute(['filename' => [$filename]], ['decorated' => false]);
60 | }
61 |
62 | public function testLintFileCompileTimeException(): void
63 | {
64 | $tester = $this->createCommandTester();
65 | $filename = $this->createFile("{{ 2|number_format(2, decimal_point='.', ',') }}");
66 |
67 | $ret = $tester->execute(['filename' => [$filename]], ['decorated' => false]);
68 |
69 | $this->assertEquals(1, $ret, 'Returns 1 in case of error');
70 | $this->assertRegExp('/ERROR in \S+ \(line /', trim($tester->getDisplay()));
71 | }
72 |
73 | /**
74 | * @return CommandTester
75 | */
76 | private function createCommandTester()
77 | {
78 | $command = new LintCommand(new Environment(new FilesystemLoader()));
79 |
80 | $application = new Application();
81 | $application->add($command);
82 | $command = $application->find('lint');
83 |
84 | return new CommandTester($command);
85 | }
86 |
87 | /**
88 | * @return string Path to the new file
89 | */
90 | private function createFile(string $content)
91 | {
92 | $filename = tempnam(sys_get_temp_dir(), 'sf-');
93 | file_put_contents($filename, $content);
94 |
95 | $this->files[] = $filename;
96 |
97 | return $filename;
98 | }
99 |
100 | protected function setUp(): void
101 | {
102 | $this->files = [];
103 | }
104 |
105 | protected function tearDown(): void
106 | {
107 | foreach ($this->files as $file) {
108 | if (file_exists($file)) {
109 | unlink($file);
110 | }
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------