├── .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 | [![Build Status](https://travis-ci.com/sserbin/twig-linter.svg?branch=master)](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 | --------------------------------------------------------------------------------