diff options
-rw-r--r-- | .github/workflows/lint-and-analyse-php.yml | 2 | ||||
-rw-r--r-- | composer.json | 4 | ||||
-rw-r--r-- | libraries/classes/Command/TwigLintCommand.php | 274 | ||||
-rwxr-xr-x | scripts/console | 6 | ||||
-rw-r--r-- | test/classes/Command/TwigLintCommandTest.php | 188 | ||||
-rw-r--r-- | test/classes/_data/file_listing/subfolder/one.ini | 1 | ||||
-rw-r--r-- | test/classes/_data/file_listing/subfolder/zero.txt | 1 |
7 files changed, 468 insertions, 8 deletions
diff --git a/.github/workflows/lint-and-analyse-php.yml b/.github/workflows/lint-and-analyse-php.yml index b9eb9a7d3d..1b0aa68ae1 100644 --- a/.github/workflows/lint-and-analyse-php.yml +++ b/.github/workflows/lint-and-analyse-php.yml @@ -75,7 +75,7 @@ jobs: - name: Check coding-standard run: composer phpcs - name: Check twig templates - run: php scripts/console lint:twig templates --show-deprecations + run: composer run twig-lint analyse-php: runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index 9a3ce40736..7828d719c6 100644 --- a/composer.json +++ b/composer.json @@ -96,8 +96,6 @@ "roave/security-advisories": "dev-latest", "samyoul/u2f-php-server": "^1.1", "symfony/console": "^5.2.3", - "symfony/finder": "^5.2.3", - "symfony/twig-bridge": "^5.2.3", "tecnickcom/tcpdf": "^6.4.1", "vimeo/psalm": "^4.8.1" }, @@ -119,7 +117,7 @@ "@phpunit" ], "update:baselines": "phpstan analyse --generate-baseline && psalm --set-baseline=psalm-baseline.xml", - "twig-lint": "php scripts/console lint:twig templates --ansi --show-deprecations" + "twig-lint": "php scripts/console lint:twig --ansi --show-deprecations" }, "config":{ "sort-packages": true, diff --git a/libraries/classes/Command/TwigLintCommand.php b/libraries/classes/Command/TwigLintCommand.php new file mode 100644 index 0000000000..07742a2ec7 --- /dev/null +++ b/libraries/classes/Command/TwigLintCommand.php @@ -0,0 +1,274 @@ +<?php + +declare(strict_types=1); + +namespace PhpMyAdmin\Command; + +use PhpMyAdmin\Template; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Twig\Error\Error; +use Twig\Loader\ArrayLoader; +use Twig\Source; + +use function array_push; +use function closedir; +use function count; +use function explode; +use function file_get_contents; +use function is_dir; +use function is_file; +use function max; +use function min; +use function opendir; +use function preg_match; +use function readdir; +use function restore_error_handler; +use function set_error_handler; +use function sprintf; + +use const DIRECTORY_SEPARATOR; +use const E_USER_DEPRECATED; + +/** + * Command that will validate your template syntax and output encountered errors. + * Author: Marc Weistroff <marc.weistroff@sensiolabs.com> + * Author: Jérôme Tamarelle <jerome@tamarelle.net> + * + * Copyright (c) 2013-2021 Fabien Potencier + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished + * to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +class TwigLintCommand extends Command +{ + /** @var string|null */ + protected static $defaultName = 'lint:twig'; + + /** @var string|null */ + protected static $defaultDescription = 'Lint a Twig template and outputs encountered errors'; + + /** + * @return void + */ + protected function configure() + { + $this + ->setDescription((string) self::$defaultDescription) + ->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors'); + } + + protected function findFiles(string $baseFolder): array + { + /* Open the handle */ + $handle = @opendir($baseFolder); + if ($handle === false) { + return []; + } + + $foundFiles = []; + + while (($file = readdir($handle)) !== false) { + if ($file === '.' || $file === '..') { + continue; + } + + $itemPath = $baseFolder . DIRECTORY_SEPARATOR . $file; + + if (is_dir($itemPath)) { + array_push($foundFiles, ...$this->findFiles($itemPath)); + continue; + } + + if (! is_file($itemPath)) { + continue; + } + + $foundFiles[] = $itemPath; + } + + /* Close the handle */ + closedir($handle); + + return $foundFiles; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $showDeprecations = $input->getOption('show-deprecations'); + + if ($showDeprecations) { + $prevErrorHandler = set_error_handler( + static function (int $level, string $message, string $file, int $line) use (&$prevErrorHandler) { + if ($level === E_USER_DEPRECATED) { + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = (int) $matches[1]; + } + + throw new Error($message, $templateLine); + } + + return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; + } + ); + } + + try { + $filesInfo = $this->getFilesInfo(ROOT_PATH . 'templates'); + } finally { + if ($showDeprecations) { + restore_error_handler(); + } + } + + return $this->display($output, $io, $filesInfo); + } + + protected function getFilesInfo(string $templatesPath): array + { + $filesInfo = []; + $filesFound = $this->findFiles($templatesPath); + foreach ($filesFound as $file) { + $filesInfo[] = $this->validate($this->getTemplateContents($file), $file); + } + + return $filesInfo; + } + + /** + * Allows easier testing + */ + protected function getTemplateContents(string $filePath): string + { + return (string) file_get_contents($filePath); + } + + private function validate(string $template, string $file): array + { + $twig = Template::getTwigEnvironment(null); + + $realLoader = $twig->getLoader(); + try { + $temporaryLoader = new ArrayLoader([$file => $template]); + $twig->setLoader($temporaryLoader); + $nodeTree = $twig->parse($twig->tokenize(new Source($template, $file))); + $twig->compile($nodeTree); + $twig->setLoader($realLoader); + } catch (Error $e) { + $twig->setLoader($realLoader); + + return [ + 'template' => $template, + 'file' => $file, + 'line' => $e->getTemplateLine(), + 'valid' => false, + 'exception' => $e, + ]; + } + + return ['template' => $template, 'file' => $file, 'valid' => true]; + } + + private function display(OutputInterface $output, SymfonyStyle $io, array $filesInfo): int + { + $errors = 0; + + foreach ($filesInfo as $info) { + if ($info['valid'] && $output->isVerbose()) { + $io->comment('<info>OK</info>' . ($info['file'] ? sprintf(' in %s', $info['file']) : '')); + } elseif (! $info['valid']) { + ++$errors; + $this->renderException($io, $info['template'], $info['exception'], $info['file']); + } + } + + if ($errors === 0) { + $io->success(sprintf('All %d Twig files contain valid syntax.', count($filesInfo))); + + return Command::SUCCESS; + } + + $io->warning( + sprintf( + '%d Twig files have valid syntax and %d contain errors.', + count($filesInfo) - $errors, + $errors + ) + ); + + return Command::FAILURE; + } + + private function renderException( + SymfonyStyle $output, + string $template, + Error $exception, + ?string $file = null + ): void { + $line = $exception->getTemplateLine(); + + if ($file) { + $output->text(sprintf('<error> ERROR </error> in %s (line %s)', $file, $line)); + } else { + $output->text(sprintf('<error> ERROR </error> (line %s)', $line)); + } + + // If the line is not known (this might happen for deprecations if we fail at detecting the line for instance), + // we render the message without context, to ensure the message is displayed. + if ($line <= 0) { + $output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage())); + + return; + } + + foreach ($this->getContext($template, $line) as $lineNumber => $code) { + $output->text(sprintf( + '%s %-6s %s', + $lineNumber === $line ? '<error> >> </error>' : ' ', + $lineNumber, + $code + )); + if ($lineNumber !== $line) { + continue; + } + + $output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage())); + } + } + + private function getContext(string $template, int $line, int $context = 3): array + { + $lines = explode("\n", $template); + + $position = max(0, $line - $context); + $max = min(count($lines), $line - 1 + $context); + + $result = []; + while ($position < $max) { + $result[$position + 1] = $lines[$position]; + ++$position; + } + + return $result; + } +} diff --git a/scripts/console b/scripts/console index 28d9fe84d2..1893d86eac 100755 --- a/scripts/console +++ b/scripts/console @@ -4,12 +4,11 @@ use PhpMyAdmin\Command\CacheWarmupCommand; use PhpMyAdmin\Command\SetVersionCommand; use PhpMyAdmin\Command\WriteGitRevisionCommand; +use PhpMyAdmin\Command\TwigLintCommand; use PhpMyAdmin\Config; use PhpMyAdmin\Core; use PhpMyAdmin\DatabaseInterface; -use PhpMyAdmin\Template; use PhpMyAdmin\Tests\Stubs\DbiDummy; -use Symfony\Bridge\Twig\Command\LintCommand; use Symfony\Component\Console\Application; if (! defined('ROOT_PATH')) { @@ -31,12 +30,11 @@ $cfg['environment'] = 'production'; $config = new Config(CONFIG_FILE); $config->set('environment', $cfg['environment']); $dbi = new DatabaseInterface(new DbiDummy()); -$twig = Template::getTwigEnvironment(ROOT_PATH . 'twig-templates'); $application = new Application('phpMyAdmin Console Tool'); $application->add(new CacheWarmupCommand()); -$application->add(new LintCommand($twig)); +$application->add(new TwigLintCommand()); $application->add(new SetVersionCommand()); $application->add(new WriteGitRevisionCommand()); diff --git a/test/classes/Command/TwigLintCommandTest.php b/test/classes/Command/TwigLintCommandTest.php new file mode 100644 index 0000000000..a74314992f --- /dev/null +++ b/test/classes/Command/TwigLintCommandTest.php @@ -0,0 +1,188 @@ +<?php + +declare(strict_types=1); + +namespace PhpMyAdmin\Tests\Command; + +use PhpMyAdmin\Command\TwigLintCommand; +use PhpMyAdmin\Tests\AbstractTestCase; +use Symfony\Component\Console\Command\Command; +use Twig\Error\SyntaxError; +use Twig\Source; + +use function class_exists; +use function sort; + +use const SORT_NATURAL; +use const SORT_REGULAR; + +/** + * @covers \PhpMyAdmin\Command\TwigLintCommand + */ +class TwigLintCommandTest extends AbstractTestCase +{ + /** @var TwigLintCommand */ + private $command; + + public function setUp(): void + { + global $cfg, $config; + + if (! class_exists(Command::class)) { + $this->markTestSkipped('The Symfony Console is missing'); + } + + parent::loadContainerBuilder(); + $cfg['environment'] = 'development'; + $config = null; + + $this->command = new TwigLintCommand(); + } + + /** + * @covers getTemplateContents + */ + public function testGetTemplateContents(): void + { + $contents = $this->callFunction($this->command, TwigLintCommand::class, 'getTemplateContents', [ + ROOT_PATH . 'test/classes/_data/file_listing/subfolder/one.ini', + ]); + + $this->assertSame('key=value' . "\n", $contents); + } + + /** + * @covers findFiles + */ + public function testFindFiles(): void + { + $filesFound = $this->callFunction($this->command, TwigLintCommand::class, 'findFiles', [ + ROOT_PATH . 'test/classes/_data/file_listing', + ]); + + // Sort results to avoid file system test specific failures + sort($filesFound, SORT_NATURAL); + + $this->assertEquals([ + ROOT_PATH . 'test/classes/_data/file_listing/one.txt', + ROOT_PATH . 'test/classes/_data/file_listing/subfolder/one.ini', + ROOT_PATH . 'test/classes/_data/file_listing/subfolder/zero.txt', + ROOT_PATH . 'test/classes/_data/file_listing/two.md', + ], $filesFound); + } + + /** + * @covers getFilesInfo + */ + public function testGetFilesInfo(): void + { + $filesInfos = $this->callFunction($this->command, TwigLintCommand::class, 'getFilesInfo', [ + ROOT_PATH . 'test/classes/_data/file_listing', + ]); + + // Sort results to avoid file system test specific failures + sort($filesInfos, SORT_REGULAR); + + $this->assertEquals([ + [ + 'template' => '', + 'file' => ROOT_PATH . 'test/classes/_data/file_listing/one.txt', + 'valid' => true, + ], + [ + 'template' => '', + 'file' => ROOT_PATH . 'test/classes/_data/file_listing/two.md', + 'valid' => true, + ], + [ + 'template' => '0000' . "\n", + 'file' => ROOT_PATH . 'test/classes/_data/file_listing/subfolder/zero.txt', + 'valid' => true, + ], + [ + 'template' => 'key=value' . "\n", + 'file' => ROOT_PATH . 'test/classes/_data/file_listing/subfolder/one.ini', + 'valid' => true, + ], + ], $filesInfos); + } + + /** + * @covers validate + */ + public function testGetFilesInfoInvalidFile(): void + { + $command = $this->getMockBuilder(TwigLintCommand::class) + ->onlyMethods(['getTemplateContents', 'findFiles']) + ->getMock(); + + $command->expects($this->exactly(1)) + ->method('findFiles') + ->willReturn( + [ + 'foo.twig', + 'foo-invalid.twig', + ] + ); + + $command->expects($this->exactly(2)) + ->method('getTemplateContents') + ->withConsecutive( + ['foo.twig'], + ['foo-invalid.twig'] + ) + ->willReturnOnConsecutiveCalls( + '{{ file }}', + '{{ file }' + ); + + $filesFound = $this->callFunction($command, TwigLintCommand::class, 'getFilesInfo', [ + ROOT_PATH . 'test/classes/_data/file_listing', + ]); + + $this->assertEquals([ + [ + 'template' => '{{ file }}', + 'file' => 'foo.twig', + 'valid' => true, + ], + [ + 'template' => '{{ file }', + 'file' => 'foo-invalid.twig', + 'valid' => false, + 'line' => 1, + 'exception' => new SyntaxError('Unexpected "}".', 1, new Source( + '{{ file }', + 'foo-invalid.twig' + )), + ], + ], $filesFound); + } + + /** + * @covers getContext + */ + public function testGetContext(): void + { + $context = $this->callFunction($this->command, TwigLintCommand::class, 'getContext', [ + '{{ file }', + 0, + ]); + + $this->assertEquals([1 => '{{ file }'], $context); + + $context = $this->callFunction($this->command, TwigLintCommand::class, 'getContext', [ + '{{ file }', + 3, + ]); + + $this->assertEquals([1 => '{{ file }'], $context); + + $context = $this->callFunction($this->command, TwigLintCommand::class, 'getContext', [ + '{{ file }', + 5, + ]); + + $this->assertEquals([], $context); + } +} diff --git a/test/classes/_data/file_listing/subfolder/one.ini b/test/classes/_data/file_listing/subfolder/one.ini new file mode 100644 index 0000000000..7b89edbafe --- /dev/null +++ b/test/classes/_data/file_listing/subfolder/one.ini @@ -0,0 +1 @@ +key=value diff --git a/test/classes/_data/file_listing/subfolder/zero.txt b/test/classes/_data/file_listing/subfolder/zero.txt new file mode 100644 index 0000000000..739d79706d --- /dev/null +++ b/test/classes/_data/file_listing/subfolder/zero.txt @@ -0,0 +1 @@ +0000 |