diff --git a/.travis.yml b/.travis.yml index ce4dcac..5939114 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: php +dist: trusty + sudo: false php: - - 5.3 - 5.4 - 5.5 - 5.6 @@ -13,3 +14,8 @@ php: before_script: composer install script: phpunit + +matrix: + include: # https://github.com/travis-ci/travis-ci/issues/7712#issuecomment-300553336 + - php: "5.3" + dist: precise diff --git a/composer.json b/composer.json index 4183fce..dd819a5 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,13 @@ "require": { "symfony/console": "^2.1 || ^3.0", "symfony/finder": "^2.1 || ^3.0", - "twig/twig": "^1.16.2" + "twig/twig": "^1.16.2", + "symfony/config": "^2.8 || ^3.0", + "symfony/stopwatch": "^2.8 || ^3.0", + "aura/autoload": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^5.5 || ^6.2" }, "autoload": { "psr-0": { "Asm89\\Twig\\Lint\\": "src/" } diff --git a/src/Asm89/Twig/Lint/Command/LintCommand.php b/src/Asm89/Twig/Lint/Command/LintCommand.php index 6555f7f..2b3bffb 100644 --- a/src/Asm89/Twig/Lint/Command/LintCommand.php +++ b/src/Asm89/Twig/Lint/Command/LintCommand.php @@ -50,6 +50,20 @@ protected function configure() InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Excludes, based on regex, paths of files and folders from parsing' ), + new InputOption( + 'stub-tag', + '', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'List of tags that the lint command has to provide stub for', + array() + ), + new InputOption( + 'stub-test', + '', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'List of tests that the lint command has to provide stub for', + array() + ), new InputOption('only-print-errors', '', InputOption::VALUE_NONE), new InputOption('summary', '', InputOption::VALUE_NONE) )) @@ -81,12 +95,20 @@ protected function configure() protected function execute(InputInterface $input, CliOutputInterface $output) { - $twig = new StubbedEnvironment(new \Twig_Loader_String()); - $template = null; - $filename = $input->getArgument('filename'); - $exclude = $input->getOption('exclude'); - $summary = $input->getOption('summary'); - $output = $this->getOutput($output, $input->getOption('format')); + $template = null; + $filename = $input->getArgument('filename'); + $exclude = $input->getOption('exclude'); + $stubTagList = $input->getOption('stub-tag'); + $stubTestsList = $input->getOption('stub-test'); + $summary = $input->getOption('summary'); + $output = $this->getOutput($output, $input->getOption('format')); + $twig = new StubbedEnvironment( + new \Twig_Loader_Array(), + array( + 'stub_tags' => $stubTagList, + 'stub_tests' => $stubTestsList, + ) + ); if (!$filename) { if (0 !== ftell(STDIN)) { @@ -151,7 +173,7 @@ protected function validateTemplate( ) { try { - $twig->parse($twig->tokenize($template, $file ? (string) $file : null)); + $twig->parse($twig->tokenize(new \Twig_Source($template, $file ? (string) $file : null))); if (false === $onlyPrintErrors) { $output->ok($template, $file); } diff --git a/src/Asm89/Twig/Lint/Command/TwigCSCommand.php b/src/Asm89/Twig/Lint/Command/TwigCSCommand.php new file mode 100644 index 0000000..4a8dd9b --- /dev/null +++ b/src/Asm89/Twig/Lint/Command/TwigCSCommand.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Command; + +use Asm89\Twig\Lint\Config; +use Asm89\Twig\Lint\Linter; +use Asm89\Twig\Lint\RulesetFactory; +use Asm89\Twig\Lint\StubbedEnvironment; +use Asm89\Twig\Lint\Config\Loader; +use Asm89\Twig\Lint\Report\TextFormatter; +use Asm89\Twig\Lint\Tokenizer\Tokenizer; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\Finder\Finder; + +/** + * TwigCS stands for "Twig Code Sniffer" and will check twig template againt all + * rules which have been defined in the twigcs.yml of your project. + * + * This is heavily inspired by the symfony lint command and PHP_CodeSniffer tool + * (https://github.com/squizlabs/PHP_CodeSniffer). + * + * @author Hussard + */ +class TwigCSCommand extends Command +{ + protected function configure() + { + $this + ->setName('twigcs') + ->setDescription('Lints a template and outputs encountered errors') + ->setDefinition(array( + new InputOption( + 'exclude', + '', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Excludes, based on regex, paths of files and folders from parsing', + array() + ), + new InputOption( + 'format', + '', + InputOption::VALUE_OPTIONAL, + 'Implemented formats are: full', + 'full' + ), + new InputOption( + 'level', + '', + InputOption::VALUE_OPTIONAL, + 'Allowed values are: warning, error', + 'warning' + ), + new InputOption( + 'severity', + '', + InputOption::VALUE_OPTIONAL, + 'Allowed values are: 0 - 10', + '' + ), + new InputOption( + 'working-dir', + '', + InputOption::VALUE_OPTIONAL, + 'Run as if this was started in instead of the current working directory', + getcwd() + ), + )) + ->addArgument('filename', InputArgument::OPTIONAL) + ->setHelp(<<%command.name% will check twig templates against a set of rules defined in +a "twigcs.yml". + +php %command.full_name% filename + +The command gets the contents of filename and outputs violations of the rules to stdout. + +php %command.full_name% dirname + +The command finds all twig templates in dirname and validates the syntax +of each Twig templates. + +EOF + ) + ; + } + + /** + * @{inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $filename = $input->getArgument('filename'); + $exclude = $input->getOption('exclude'); + $format = $input->getOption('format'); + $level = $input->getOption('level'); + $severity = $input->getOption('severity'); + $currentDir = $input->getOption('working-dir'); + + // Load config files. + $globalLoader = new Loader(new FileLocator(getenv('HOME') . '/.twigcs')); + $loader = new Loader(new FileLocator($currentDir)); + + $globalConfig = array(); + try { + $globalConfig = $globalLoader->load('twigcs_global.yml'); + } catch (\Exception $e) { + // The global config file may not exist but it's ok. + } + + // Compute the final config object. + $config = new Config( + $globalConfig, + $loader->load('twigcs.yml'), + array('workingDirectory' => $currentDir) + ); + + $twig = new StubbedEnvironment(new \Twig_Loader_Array(), array('stub_tags' => $config->get('stub'))); + $linter = new Linter($twig, new Tokenizer($twig)); + $factory = new RulesetFactory(); + $reporter = $this->getReportFormatter($input, $output, $format); + $exitCode = 0; + + // Get the rules to apply. + $ruleset = $factory->createRulesetFromConfig($config); + + // Execute the linter. + $report = $linter->run($config->findFiles(), $ruleset); + + // Format the output. + $reporter->display($report, array( + 'level' => $level, + 'severity' => $severity, + )); + + // Return a meaningful error code. + if ($report->getTotalErrors()) { + $exitCode = 1; + } + + return $exitCode; + } + + protected function getReportFormatter($input, $output, $format) + { + switch ($format) { + case 'full': + return new TextFormatter($input, $output, array('explain' => true)); + case 'text': + return new TextFormatter($input, $output); + default: + throw new \Exception(sprintf('Unknown format "%s"', $format)); + } + } +} diff --git a/src/Asm89/Twig/Lint/Config.php b/src/Asm89/Twig/Lint/Config.php new file mode 100644 index 0000000..0503cdf --- /dev/null +++ b/src/Asm89/Twig/Lint/Config.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint; + +use Aura\Autoload\Loader as Autoloader; +use Symfony\Component\Finder\Finder; + +/** + * TwigCS configuration data. + * + * @author Hussard + */ +class Config +{ + /** + * Default configuration. + * + * @var array + */ + public static $defaultConfig = array( + 'exclude' => array(), + 'pattern' => '*.twig', + 'paths' => array(), + 'standardPaths' => array(), + 'stub' => array(), + 'workingDirectory' => '', + ); + + /** + * Current configuration. + * + * @var array + */ + protected $config; + + /** + * Autoloader for sniffs. + * + * @var Autoloader\Loader + */ + protected $autoloader; + + /** + * Constructor. + */ + public function __construct() + { + $args = func_get_args(); + + $this->config = $this::$defaultConfig; + foreach ($args as $arg) { + $this->config = array_merge($this->config, $arg); + } + + $this->autoloader = new Autoloader(); + $this->autoloader->register(); + + $standardPaths = $this->get('standardPaths'); + if ($standardPaths) { + $this->autoloader->setPrefixes($standardPaths); + } + } + + /** + * Find all files to process, based on a file or directory and exclude patterns. + * + * @param string $fileOrDirectory a file or a directory. + * @param array $exclude array of exclude patterns. + * + * @return array + */ + public function findFiles($fileOrDirectory = null, $exclude = null) + { + $files = array(); + + if (is_file($fileOrDirectory)) { + // Early return with the given file. Should we exclude things to here? + return array($fileOrDirectory); + } + + if (is_dir($fileOrDirectory)) { + $fileOrDirectory = array($fileOrDirectory); + } + + if (!$fileOrDirectory) { + $fileOrDirectory = $this->get('paths'); + $exclude = $this->get('exclude'); + } + + // Build the finder. + $files = Finder::create() + ->in($this->get('workingDirectory')) + ->name($this->config['pattern']) + ->files() + ; + + // Include all matching paths. + foreach ($fileOrDirectory as $path) { + $files->path($path); + } + + // Exclude all matching paths. + if ($exclude) { + $files->exclude($exclude); + } + + return $files; + } + + /** + * Get a configuration value for the given $key. + * + * @param string $key + * + * @return any + */ + public function get($key) + { + if (!isset($this->config[$key])) { + throw new \Exception(sprintf('Configuration key "%s" does not exist', $key)); + } + + return $this->config[$key]; + } +} diff --git a/src/Asm89/Twig/Lint/Config/Loader.php b/src/Asm89/Twig/Lint/Config/Loader.php new file mode 100644 index 0000000..ed21d34 --- /dev/null +++ b/src/Asm89/Twig/Lint/Config/Loader.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Config; + +use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader; +use Symfony\Component\Yaml\Parser; + +/** + * Load a twigcs.yml file and validate its content. + * + * @author Hussard + */ +class Loader extends BaseFileLoader +{ + /** + * {@inheritdoc} + */ + public function load($resource, $type = null) + { + if (!stream_is_local($resource)) { + throw new \InvalidArgumentException(sprintf('This is not a local file "%s".', $resource)); + } + + // Try to find the path to the resource. + try { + $path = $this->locator->locate($resource); + } catch (\InvalidArgumentException $e) { + throw new \Exception(sprintf('File "%s" not found.', $resource), null, $e); + } + + // Load and parse the resource. + $content = $this->loadResource($path); + if (!$content) { + // Empty resource, always return an array. + $content = array(); + } + + return $this->validate($content, $path); + } + + /** + * {@inheritdoc} + */ + public function supports($resource, $type = null) + { + $validTypes = array('yaml', 'yml'); + return is_string($resource) && in_array(pathinfo($resource, PATHINFO_EXTENSION), $validTypes, true) + && (!$type || in_array($type, $validTypes)); + } + + /** + * Load a resource and returns the parsed content. + * + * @param string $resource + * + * @return array + * + * @throws InvalidResourceException If stream content has an invalid format. + */ + public function loadResource($file) + { + $parser = new Parser(); + try { + return $parser->parse(file_get_contents($file)); + } catch (\Exception $e) { + throw new \Exception(sprintf('Error parsing YAML, invalid file "%s"', $file), 0, $e); + } + } + + /** + * Validates the content $content parsed from $file. + * + * This default method, returns the content, as is, without any form of + * validation. + * + * @param mixed $content + * @param string $file + * + * @return array + */ + protected function validate($content, $file) + { + if (!isset($content['ruleset'])) { + throw new \Exception(sprintf('Missing "%s" key', 'ruleset')); + } + + foreach ($content['ruleset'] as $rule) { + if (!isset($rule['class'])) { + throw new \Exception(sprintf('Missing "%s" key', 'class')); + } + } + + return $content; + } +} diff --git a/src/Asm89/Twig/Lint/Console/Application.php b/src/Asm89/Twig/Lint/Console/Application.php index 8e94238..1ed648a 100644 --- a/src/Asm89/Twig/Lint/Console/Application.php +++ b/src/Asm89/Twig/Lint/Console/Application.php @@ -12,6 +12,7 @@ namespace Asm89\Twig\Lint\Console; use Asm89\Twig\Lint\Command\LintCommand; +use Asm89\Twig\Lint\Command\TwigCSCommand; use Symfony\Component\Console\Application as BaseApplication; /** @@ -28,6 +29,7 @@ protected function getDefaultCommands() { $commands = parent::getDefaultCommands(); $commands[] = new LintCommand(); + $commands[] = new TwigCSCommand(); return $commands; } diff --git a/src/Asm89/Twig/Lint/Extension/SniffsExtension.php b/src/Asm89/Twig/Lint/Extension/SniffsExtension.php new file mode 100644 index 0000000..9527e25 --- /dev/null +++ b/src/Asm89/Twig/Lint/Extension/SniffsExtension.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Extension; + +use Asm89\Twig\Lint\NodeVisitor\SniffsNodeVisitor; +use Asm89\Twig\Lint\Sniffs\PostParserSniffInterface; + +/** + * This extension is responsible of loading the sniffs into the twig environment. + * + * This class is only a bridge between the linter and the `SniffsNodeVisitor` that is + * actually doing the work when Twig parser is compiling a template. + * + * @author Hussard + */ +class SniffsExtension extends \Twig_Extension +{ + /** + * The actual node visitor. + * + * @var SniffsNodeVisitor + */ + protected $nodeVisitor; + + public function __construct() + { + $this->nodeVisitor = new SniffsNodeVisitor(); + } + + /** + * {@inheritDoc} + */ + public function getNodeVisitors() + { + return array($this->nodeVisitor); + } + + /** + * Register a sniff in the node visitor. + * + * @param PostParserSniffInterface $sniff + */ + public function addSniff(PostParserSniffInterface $sniff) + { + $this->nodeVisitor->addSniff($sniff); + + return $this; + } + + /** + * Remove a sniff from the node visitor. + * + * @param PostParserSniffInterface $sniff + * + * @return self + */ + public function removeSniff(PostParserSniffInterface $sniff) + { + $this->nodeVisitor->removeSniff($sniff); + + return $this; + } +} diff --git a/src/Asm89/Twig/Lint/Extension/StubbedCore.php b/src/Asm89/Twig/Lint/Extension/StubbedCore.php deleted file mode 100644 index bcc4b59..0000000 --- a/src/Asm89/Twig/Lint/Extension/StubbedCore.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Asm89\Twig\Lint\Extension; - -/** - * Overridden core extension to stub tests. - * - * @author Alexander - */ -class StubbedCore extends \Twig_Extension_Core -{ - /** - * Return a class name for every test name. - * - * @param \Twig_Parser $parser - * @param string $name - * @param integer $line - * - * @return string - */ - protected function getTestNodeClass(\Twig_Parser $parser, $name) - { - return 'Twig_Node_Expression_Test'; - } - - protected function getTestName(\Twig_Parser $parser, $line) - { - try { - return parent::getTestName($parser, $line); - } catch (\Twig_Error_Syntax $exception) { - return 'null'; - } - } -} diff --git a/src/Asm89/Twig/Lint/Linter.php b/src/Asm89/Twig/Lint/Linter.php new file mode 100644 index 0000000..b443398 --- /dev/null +++ b/src/Asm89/Twig/Lint/Linter.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint; + +use Asm89\Twig\Lint\Report\ReportWatch; +use Asm89\Twig\Lint\Report\SniffViolation; +use Asm89\Twig\Lint\Tokenizer\TokenizerInterface; +use Asm89\Twig\Lint\Sniffs\SniffInterface; +use Asm89\Twig\Lint\Sniffs\PostParserSniffInterface; + +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * Linter is the main class and will process twig files against a set of rules. + * + * @author Hussard + */ +class Linter +{ + protected $env; + + protected $sniffExtension; + + protected $tokenizer; + + public function __construct(\Twig_Environment $env, TokenizerInterface $tokenizer) + { + $this->env = $env; + $this->sniffExtension = $this->env->getExtension('Asm89\Twig\Lint\Extension\SniffsExtension'); + $this->tokenizer = $tokenizer; + } + + /** + * Run the linter on the given $files against the given $ruleset. + * + * @param array $files List of files to process. + * @param Ruleset $ruleset Set of rules to check. + * + * @return Report an object with all violations and stats. + */ + public function run($files, Ruleset $ruleset) + { + if (!is_array($files) && !$files instanceof \Traversable) { + $files = array($files); + } + + if (empty($files)) { + throw new \Exception('No files to process, provide at least one file to be linted'); + } + + // setUp + $stopwatch = new Stopwatch(); + + $report = new Report(); + set_error_handler(function ($type, $msg) use ($report) { + if (E_USER_DEPRECATED === $type) { + $sniffViolation = new SniffViolation( + SniffInterface::MESSAGE_TYPE_NOTICE, + $msg, + '', + '' + ); + + $report->addMessage($sniffViolation); + } + }); + + foreach ($ruleset->getSniffs() as $sniff) { + if ($sniff instanceof PostParserSniffInterface) { + $this->sniffExtension->addSniff($sniff); + } + + $sniff->enable($report); + } + + $stopwatch->start('lint'); + + // Process + foreach ($files as $file) { + $this->processTemplate($file, $ruleset, $report); + $stopwatch->lap('lint'); + + // Add this file to the report. + $report->addFile($file); + } + + // tearDown + restore_error_handler(); + foreach ($ruleset->getSniffs() as $sniff) { + if ($sniff instanceof PostParserSniffInterface) { + $this->sniffExtension->removeSniff($sniff); + } + + $sniff->disable(); + } + + $report->setSummary( + new ReportWatch($stopwatch->getEvent('lint')->getDuration(), $stopwatch->getEvent('lint')->getMemory()) + ); + + return $report; + } + + /** + * Checks one template against the set of rules. + * + * @param string $file File to check as a string. + * @param Ruleset $ruleset Set of rules to check. + * @param Report $report Current report to fill. + * + * @return boolean + */ + public function processTemplate($file, $ruleset, $report) + { + $twigSource = new \Twig_Source(file_get_contents($file), $file, $file); + + // Tokenize + Parse. + try { + $this->env->parse($this->env->tokenize($twigSource)); + } catch (\Twig_Error $e) { + $sourceContext = $e->getSourceContext(); + + $sniffViolation = new SniffViolation( + SniffInterface::MESSAGE_TYPE_ERROR, + $e->getRawMessage(), + $e->getTemplateLine(), + $e->getSourceContext()->getName() + ); + $sniffViolation->setSeverity(SniffInterface::SEVERITY_MAX); + + $report->addMessage($sniffViolation); + + return false; + } + + // Tokenizer. + try { + $stream = $this->tokenizer->tokenize($twigSource); + } catch (\Exception $e) { + $sniffViolation = new SniffViolation( + SniffInterface::MESSAGE_TYPE_ERROR, + sprintf('Unable to tokenize file "%s"', (string) $file), + '', + (string) $file + ); + $sniffViolation->setSeverity(SniffInterface::SEVERITY_MAX); + + $report->addMessage($sniffViolation); + + return false; + } + + $sniffs = $ruleset->getSniffs(SniffInterface::TYPE_PRE_PARSER); + foreach ($sniffs as $sniff) { + foreach ($stream as $index => $token) { + $sniff->process($token, $index, $stream); + } + } + + return true; + } +} diff --git a/src/Asm89/Twig/Lint/NodeVisitor/SniffsNodeVisitor.php b/src/Asm89/Twig/Lint/NodeVisitor/SniffsNodeVisitor.php new file mode 100644 index 0000000..d5f5b98 --- /dev/null +++ b/src/Asm89/Twig/Lint/NodeVisitor/SniffsNodeVisitor.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\NodeVisitor; + +use Asm89\Twig\Lint\Sniffs\PostParserSniffInterface; + +/** + * Node visitors provide a mechanism for manipulating nodes before a template is + * compiled down to a PHP class. + * + * This class is using that mechanism to execute sniffs (rules) on all the twig + * node during a template compilation; thanks to `Twig_Parser`. + * + * @author Hussard + */ +class SniffsNodeVisitor extends \Twig_BaseNodeVisitor implements \Twig_NodeVisitorInterface +{ + /** + * List of sniffs to be executed. + * + * @var array + */ + protected $sniffs; + + /** + * Is this node visitor enabled? + * + * @var bool + */ + protected $enabled; + + public function __construct() + { + $this->sniffs = array(); + $this->enabled = true; + } + + /** + * {@inheritdoc} + */ + protected function doEnterNode(\Twig_Node $node, \Twig_Environment $env) + { + if (!$this->enabled) { + return $node; + } + + foreach ($this->getSniffs() as $sniff) { + $sniff->process($node, $env); + } + + return $node; + } + + /** + * {@inheritdoc} + */ + protected function doLeaveNode(\Twig_Node $node, \Twig_Environment $env) + { + return $node; + } + + /** + * {@inheritdoc} + */ + public function getPriority() + { + return 0; + } + + /** + * Register a sniff to be executed. + * + * @param PostParserSniffInterface $sniff + */ + public function addSniff(PostParserSniffInterface $sniff) + { + $this->sniffs[] = $sniff; + } + + /** + * Remove a sniff from the node visitor. + * + * @param PostParserSniffInterface $sniff + * + * @return self + */ + public function removeSniff(PostParserSniffInterface $toBeRemovedSniff) + { + foreach ($this->sniffs as $index => $sniff) { + if ($toBeRemovedSniff === $sniff) { + unset($this->sniffs[$index]); + } + } + + return $this; + } + + /** + * Get all registered sniffs. + * + * @return array + */ + public function getSniffs() + { + return $this->sniffs; + } + + /** + * Enable this node visitor. + * + * @return self + */ + public function enable() + { + $this->enabled = true; + + return $this; + } + + /** + * Disable this node visitor. + * + * @return self + */ + public function disable() + { + $this->enabled = false; + } +} diff --git a/src/Asm89/Twig/Lint/Report.php b/src/Asm89/Twig/Lint/Report.php new file mode 100644 index 0000000..8d76fc2 --- /dev/null +++ b/src/Asm89/Twig/Lint/Report.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint; + +use Asm89\Twig\Lint\Report\SniffViolation; + +/** + * Report contains all violations with stats. + * + * @author Hussard + */ +class Report +{ + const MESSAGE_TYPE_NOTICE = 0; + const MESSAGE_TYPE_WARNING = 1; + const MESSAGE_TYPE_ERROR = 2; + + protected $messages; + + protected $files; + + protected $totalNotices; + + protected $totalWarnings; + + protected $totalErrors; + + public function __construct() + { + $this->messages = array(); + $this->files = array(); + $this->totalNotices = 0; + $this->totalWarnings = 0; + $this->totalErrors = 0; + } + + public function setSummary($summary) + { + $this->summary = $summary; + + return $this; + } + + public function getSummary() + { + return $this->summary; + } + + public function addMessage(SniffViolation $SniffViolation) + { + // Update stats + switch ($SniffViolation->getLevel()) { + case self::MESSAGE_TYPE_NOTICE: + ++$this->totalNotices; + + break; + case self::MESSAGE_TYPE_WARNING: + ++$this->totalWarnings; + + break; + case self::MESSAGE_TYPE_ERROR: + ++$this->totalErrors; + + break; + } + + $this->messages[] = $SniffViolation; + + return $this; + } + + public function getMessages($filters = array()) + { + if (!$filters) { + // Return all messages, without filtering. + return $this->messages; + } + + return array_filter($this->messages, function ($message) use ($filters) { + $fileFilter = $levelFilter = $severityFilter = true; + + if (isset($filters['file']) && $filters['file']) { + $fileFilter = (string) $message->getFilename() === (string) $filters['file']; + } + + if (isset($filters['level']) && $filters['level']) { + $levelFilter = $message->getLevel() >= $message::getLevelAsInt($filters['level']); + } + + if (isset($filters['severity']) && $filters['severity']) { + $severityFilter = $message->getSeverity() >= $filters['severity']; + } + + return $fileFilter && $levelFilter && $severityFilter; + }); + } + + public function addFile(\SplFileInfo $file) + { + $this->files[] = $file; + } + + public function getFiles() + { + return $this->files; + } + + public function getTotalFiles() + { + return count($this->files); + } + + public function getTotalMessages() + { + return count($this->messages); + } + + public function getTotalNotices() + { + return $this->totalNotices; + } + + public function getTotalWarnings() + { + return $this->totalWarnings; + } + + public function getTotalErrors() + { + return $this->totalErrors; + } +} diff --git a/src/Asm89/Twig/Lint/Report/ReportFormatterInterface.php b/src/Asm89/Twig/Lint/Report/ReportFormatterInterface.php new file mode 100644 index 0000000..20ed42e --- /dev/null +++ b/src/Asm89/Twig/Lint/Report/ReportFormatterInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Report; + +use Asm89\Twig\Lint\Report; + +interface ReportFormatterInterface +{ + /** + * Format and display the given $report. + * + * @param Report $report + * @param array $options + * + * @return void + */ + public function display(Report $report, array $options = array()); +} diff --git a/src/Asm89/Twig/Lint/Report/ReportWatch.php b/src/Asm89/Twig/Lint/Report/ReportWatch.php new file mode 100644 index 0000000..9e60b2c --- /dev/null +++ b/src/Asm89/Twig/Lint/Report/ReportWatch.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Report; + +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * Utility for reporting stats while linting. + * + * @author Hussard + */ +class ReportWatch +{ + /** + * Duration of the lint. + * + * @var int + */ + protected $duration; + + /** + * Memory consumption. + * + * @var int + */ + protected $memory; + + /** + * File + * @var string|\SplFileInfo + */ + protected $file; + + /** + * Constructor. + * + * @param int $duration + * @param int $memory + * @param string|\SplFileInfo $file + */ + public function __construct($duration, $memory, $file = null) + { + $this->duration = $duration; + $this->memory = $memory; + $this->file = $file; + } + + public function getDuration() + { + return $this->duration; + } + + public function getMemory() + { + return $this->memory; + } +} diff --git a/src/Asm89/Twig/Lint/Report/SniffViolation.php b/src/Asm89/Twig/Lint/Report/SniffViolation.php new file mode 100644 index 0000000..2b0c957 --- /dev/null +++ b/src/Asm89/Twig/Lint/Report/SniffViolation.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Report; + +use Asm89\Twig\Lint\Sniffs\SniffInterface; + +/** + * Wrapper class that represents a violation to a sniff with context. + * + * @author Hussard + */ +class SniffViolation +{ + /** + * Level of the message among `notice`, `warning`, `error` + * + * @var int + */ + protected $level; + + /** + * Text message associated with the violation. + * + * @var string + */ + protected $message; + + /** + * Line number for the violation. + * + * @var int + */ + protected $line; + + /** + * Position of the violation on the current line. + * + * @var int|null + */ + protected $linePosition; + + /** + * File in which the violation has been found. + * + * @var \SplFileInfo|string + */ + protected $filename; + + /** + * Severity is an indication for finer filtering of violation. + * + * @var int + */ + protected $severity; + + /** + * Sniff that has produce this violation. + * + * @var SniffInterface + */ + protected $sniff; + + public function __construct($level, $message, $line, $filename) + { + $this->level = $level; + $this->message = $message; + $this->line = $line; + $this->filename = $filename; + + $this->sniff = null; + $this->linePosition = null; + $this->severity = SniffInterface::SEVERITY_DEFAULT; + } + + /** + * Get the level of this violation. + * + * @return int + */ + public function getLevel() + { + return $this->level; + } + + /** + * Get a human-readable of the level of this violation. + * + * @return string + */ + public function getLevelAsString() + { + switch ($this->level) { + case SniffInterface::MESSAGE_TYPE_NOTICE: + return 'NOTICE'; + case SniffInterface::MESSAGE_TYPE_WARNING: + return 'WARNING'; + case SniffInterface::MESSAGE_TYPE_ERROR: + return 'ERROR'; + } + + throw new \Exception(sprintf('Unknown level "%s"', $this->level)); + } + + /** + * Get the integer value for a given string $level. + * + * @return int + */ + public static function getLevelAsInt($level) + { + switch (strtoupper($level)) { + case 'NOTICE': + return SniffInterface::MESSAGE_TYPE_NOTICE; + case 'WARNING': + return SniffInterface::MESSAGE_TYPE_WARNING; + case 'ERROR': + return SniffInterface::MESSAGE_TYPE_ERROR; + } + + throw new \Exception(sprintf('Unknown level "%s"', $level)); + } + + /** + * Get the text message of this violation. + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Get the line number where this violation occured. + * + * @return int + */ + public function getLine() + { + return $this->line; + } + + /** + * Get the filename (and path) where this violation occured. + * + * @return string + */ + public function getFilename() + { + return (string) $this->filename; + } + + /** + * Set the position in the line where this violation occured. + * + * @param int $linePosition + * + * @return self + */ + public function setLinePosition($linePosition) + { + $this->linePosition = $linePosition; + + return $this; + } + + /** + * Get the position in the line, if any. + * + * @return int + */ + public function getLinePosition() + { + return $this->linePosition; + } + + /** + * Set the severity. + * + * @param int $severity + */ + public function setSeverity($severity) + { + $this->severity = $severity; + + return $this; + } + + /** + * Get the severity level. + * + * @return int + */ + public function getSeverity() + { + return (int) $this->severity; + } + + /** + * Set the sniff that was not met. + * + * @param SniffInterface $sniff + */ + public function setSniff(SniffInterface $sniff) + { + $this->sniff = $sniff; + + return $this; + } + + /** + * Get the sniff that was not met. + * + * @return SniffInterface + */ + public function getSniff() + { + return $this->sniff; + } +} diff --git a/src/Asm89/Twig/Lint/Report/TextFormatter.php b/src/Asm89/Twig/Lint/Report/TextFormatter.php new file mode 100644 index 0000000..eac7561 --- /dev/null +++ b/src/Asm89/Twig/Lint/Report/TextFormatter.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Report; + +use Asm89\Twig\Lint\Report; + +use Symfony\Component\Console\Helper\TableCell; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Human readable output with context. + * + * @author Marc Weistroff + * @author Alexander + * @author Hussard + */ +class TextFormatter implements ReportFormatterInterface +{ + const ERROR_CURSOR_CHAR = '>>'; + const ERROR_LINE_FORMAT = '%-5s| %s'; + const ERROR_CONTEXT_LIMIT = 2; + const ERROR_LINE_WIDTH = 120; + + /** + * Input-output helper object. + * + * @var SymfonyStyle + */ + protected $io; + + /** + * Constructor. + * + * @param SymfonyStyle $output + */ + public function __construct($input, $output, $options = array()) + { + $this->io = new SymfonyStyle($input, $output); + $this->options = array_merge(array( + 'explain' => false, + ), $options); + } + + /** + * {@inheritdoc} + */ + public function display(Report $report, array $options = array()) + { + $options = array_merge($this->options, $options); + + foreach ($report->getFiles() as $file) { + $fileMessages = $report->getMessages(array( + 'file' => $file, + 'level' => isset($options['level']) ? $options['level'] : null, + 'severity' => isset($options['severity']) ? $options['severity'] : null, + )); + + $this->io->text((count($fileMessages) > 0 ? 'KO': 'OK') . ' ' . $file->getRelativePathname()); + + if ($options['explain']) { + $rows = array(); + foreach ($fileMessages as $message) { + $lines = $this->getContext(file_get_contents($file), $message->getLine(), $this::ERROR_CONTEXT_LIMIT); + + $formattedText = array(); + foreach ($lines as $no => $code) { + $formattedText[] = sprintf($this::ERROR_LINE_FORMAT, $no, wordwrap($code, $this::ERROR_LINE_WIDTH)); + + if ($no === $message->getLine()) { + $formattedText[] = sprintf( + '' . $this::ERROR_LINE_FORMAT . '', + $this::ERROR_CURSOR_CHAR, + wordwrap($message->getMessage(), $this::ERROR_LINE_WIDTH) + ); + } + } + + $rows[] = array( + new TableCell('' . $message->getLevelAsString() . '', array('rowspan' => 2)), + implode("\n", $formattedText), + ); + $rows[] = new TableSeparator(); + } + + $this->io->table(array(), $rows); + } + } + + $summaryString = sprintf( + 'Files linted: %d, notices: %d, warnings: %d, errors: %d; lint done in %dms / %s', + $report->getTotalFiles(), + $report->getTotalNotices(), + $report->getTotalWarnings(), + $report->getTotalErrors(), + $report->getSummary()->getDuration(), + $this->formatMemory($report->getSummary()->getMemory()) + ); + + if (0 === $report->getTotalWarnings() && 0 === $report->getTotalErrors()) { + $this->io->success($summaryString); + } elseif (0 < $report->getTotalWarnings() && 0 === $report->getTotalErrors()) { + $this->io->warning($summaryString); + } else { + $this->io->error($summaryString); + } + } + + protected function getContext($template, $line, $context) + { + $lines = explode("\n", $template); + + $position = max(0, $line - $context); + $max = min(count($lines), $line - 1 + $context); + + $result = array(); + $indentCount = null; + while ($position < $max) { + if (preg_match('/^([\s\t]+)/', $lines[$position], $match)) { + if ($indentCount === null) { + $indentCount = strlen($match[1]); + } + + if (strlen($match[1]) < $indentCount) { + $indentCount = strlen($match[1]); + } + } else { + $indentCount = 0; + } + + $result[$position + 1] = $lines[$position]; + $position++; + } + + foreach ($result as $index => $code) { + $result[$index] = substr($code, $indentCount); + } + + return $result; + } + + protected function formatMemory($size) + { + $units = array('B','kB','MB','GB','TB','PB','EB','ZB','YB'); + $precision = array(0, 0, 1, 2, 2, 3, 3, 4, 4); + $step = 1024; + + $i = 0; + while (($size / $step) >= 1) { + $size = $size / $step; + $i++; + } + + return round($size, $precision[$i]) . $units[$i]; + } +} diff --git a/src/Asm89/Twig/Lint/Ruleset.php b/src/Asm89/Twig/Lint/Ruleset.php new file mode 100644 index 0000000..cd71a36 --- /dev/null +++ b/src/Asm89/Twig/Lint/Ruleset.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint; + +use Asm89\Twig\Lint\Sniffs\PostParserSniffInterface; +use Asm89\Twig\Lint\Sniffs\PreParserSniffInterface; +use Asm89\Twig\Lint\Sniffs\SniffInterface; + +/** + * Set of rules to be used by TwigCS and contains all sniffs (pre or post). + * + * @author Hussard + */ +class Ruleset +{ + protected $sniffs; + + public function __construct() + { + $this->sniffs = array(); + } + + public function getSniffs($types = null) + { + if (null === $types) { + $types = array(SniffInterface::TYPE_PRE_PARSER, SniffInterface::TYPE_POST_PARSER); + } + + if (null !== $types && !is_array($types)) { + $types = array($types); + } + + return array_filter($this->sniffs, function ($sniff) use ($types) { + return in_array($sniff->getType(), $types); + }); + } + + public function addPreParserSniff(PreParserSniffInterface $sniff) + { + $this->sniffs[get_class($sniff)] = $sniff; + + return $this; + } + + public function addPostParserSniff(PostParserSniffInterface $sniff) + { + $this->sniffs[get_class($sniff)] = $sniff; + + return $this; + } + + public function addSniff(SniffInterface $sniff) + { + if (SniffInterface::TYPE_PRE_PARSER === $sniff->getType()) { + // Store this type of sniff locally. + $this->addPreParserSniff($sniff); + + return $this; + } + + if (SniffInterface::TYPE_POST_PARSER === $sniff->getType()) { + // Store this type of sniff locally. + $this->addPostParserSniff($sniff); + + return $this; + } + + throw new \Exception('Unknown type of sniff "' . $sniff->getType() . '", expected one of: "' . implode(', ', array(SniffInterface::TYPE_PRE_PARSER, SniffInterface::TYPE_POST_PARSER)) . "'"); + } + + public function removeSniff($sniffClass) + { + if (isset($this->sniffs[$sniffClass])) { + unset($this->sniffs[$sniffClass]); + } + + return $this; + } +} diff --git a/src/Asm89/Twig/Lint/RulesetFactory.php b/src/Asm89/Twig/Lint/RulesetFactory.php new file mode 100644 index 0000000..283645b --- /dev/null +++ b/src/Asm89/Twig/Lint/RulesetFactory.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint; + +use Asm89\Twig\Lint\Config\Loader; +use Symfony\Component\Config\FileLocator; + +/** + * Factory to help create set of rules. + * + * @author Hussard + */ +class RulesetFactory +{ + /** + * Create a new set of rule with the given $sniffs. + * + * @param array $sniffs + * + * @return Ruleset + */ + public function createRuleset(array $sniffs = array()) + { + $ruleset = new Ruleset(); + + foreach ($sniffs as $sniff) { + $ruleset->addSniff($sniff); + } + + return $ruleset; + } + + /** + * Create a new set of rule with the given $config. + * + * @param Config $config + * + * @return Ruleset + */ + public function createRulesetFromConfig(Config $config) + { + $rules = $config->get('ruleset'); + + $sniffs = array(); + foreach ($rules as $rule) { + $sniffOptions = array(); + if (isset($rule['options'])) { + $sniffOptions = $rule['options']; + } + + $sniffs[] = new $rule['class']($sniffOptions); + } + + return $this->createRuleset($sniffs); + } +} diff --git a/src/Asm89/Twig/Lint/Sniffs/AbstractPostParserSniff.php b/src/Asm89/Twig/Lint/Sniffs/AbstractPostParserSniff.php new file mode 100644 index 0000000..c9bc5b0 --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/AbstractPostParserSniff.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +use Asm89\Twig\Lint\Report\SniffViolation; + +/** + * Base for all post-parser sniff. + * + * A post parser sniff should be useful to check actual values of twig functions, filters + * and tags such as: ensure that a given function has at least 3 arguments or if the template + * contains an {% include %} tag. + * + * Use `AbstractPreParserSniff` sniff if you want to check syntax and code formatting. + * + * @author Hussard + */ +abstract class AbstractPostParserSniff extends AbstractSniff implements PostParserSniffInterface +{ + /** + * {@inheritDoc} + */ + public function getType() + { + return $this::TYPE_POST_PARSER; + } + + /** + * Adds a violation to the current report for the given node. + * + * @param int $messageType + * @param string $message + * @param \Twig_Node $token + * @param int $severity + * + * @return self + */ + public function addMessage($messageType, $message, \Twig_Node $node, $severity = null) + { + if (null === $severity) { + $severity = $this->options['severity']; + } + + $sniffViolation = new SniffViolation( + $messageType, + $message, + $this->getTemplateLine($node), + $this->getTemplateName($node) + ); + $sniffViolation->setSeverity($severity); + + $this->getReport()->addMessage($sniffViolation); + + return $this; + } + + public function getTemplateLine($node) + { + if (method_exists($node, 'getTemplateLine')) { + return $node->getTemplateLine(); + } + + if (method_exists($node, 'getLine')) { + return $node->getLine(); + } + + return ''; + } + + public function getTemplateName($node) + { + if (method_exists($node, 'getTemplateName')) { + return $node->getTemplateName(); + } + + if (method_exists($node, 'getFilename')) { + return $node->getFilename(); + } + + if ($node->hasAttribute('filename')) { + return $node->getAttribute('filename'); + } + + return ''; + } + + public function isNodeMatching($node, $type, $name = null) + { + $typeToClass = array( + 'filter' => function ($node, $type, $name) { + return $node instanceof \Twig_Node_Expression_Filter + && $name === $node->getNode($type)->getAttribute('value'); + }, + 'function' => function ($node, $type, $name) { + return $node instanceof \Twig_Node_Expression_Function + && $name === $node->getAttribute('name'); + }, + 'include' => function ($node, $type, $name) { + return $node instanceof \Twig_Node_Include; + }, + 'tag' => function ($node, $type, $name) { + return $node->getNodeTag() === $name /*&& $node->hasAttribute('name') + && $name === $node->getAttribute('name')*/; + }, + ); + + if (!isset($typeToClass[$type])) { + return false; + } + + return $typeToClass[$type]($node, $type, $name); + } + + public function stringifyValue($value) + { + if (null === $value) { + return 'null'; + } + + if (is_bool($value)) { + return ($value) ? 'true': 'false'; + } + + return (string) $value; + } + + public function stringifyNode($node) + { + $stringValue = ''; + + if ($node instanceof \Twig_Node_Expression_GetAttr) { + return $node->getNode('node')->getAttribute('name') . '.' . $this->stringifyNode($node->getNode('attribute')); + } elseif ($node instanceof \Twig_Node_Expression_Binary_Concat) { + return $this->stringifyNode($node->getNode('left')) . ' ~ ' . $this->stringifyNode($node->getNode('right')); + } elseif ($node instanceof \Twig_Node_Expression_Constant) { + return $node->getAttribute('value'); + } + + return $stringValue; + } +} diff --git a/src/Asm89/Twig/Lint/Sniffs/AbstractPreParserSniff.php b/src/Asm89/Twig/Lint/Sniffs/AbstractPreParserSniff.php new file mode 100644 index 0000000..b1d9db6 --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/AbstractPreParserSniff.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +use Asm89\Twig\Lint\Report\SniffViolation; +use Asm89\Twig\Lint\Tokenizer\Token; + +/** + * Base for all pre-parser sniff. + * + * A post parser sniff should be useful to check code formatting mainly such as: + * whitespaces, too many blank lines or trailing commas; + * + * Use `AbstractPostParserSniff` for higher-order checks. + * + * @author Hussard + */ +abstract class AbstractPreParserSniff extends AbstractSniff implements PreParserSniffInterface +{ + /** + * {@inheritDoc} + */ + public function getType() + { + return $this::TYPE_PRE_PARSER; + } + + /** + * Helper method to match a token of a given $type and $value. + * + * @param Token $token + * @param int $type + * @param string $value + * + * @return boolean + */ + public function isTokenMatching(Token $token, $type, $value = null) + { + return $token->getType() === $type && (null === $value || (null !== $value && $token->getValue() === $value)); + } + + /** + * Adds a violation to the current report for the given token. + * + * @param int $messageType + * @param string $message + * @param Token $token + * @param int $severity + * + * @return self + */ + public function addMessage($messageType, $message, Token $token, $severity = null) + { + if (null === $severity) { + $severity = $this->options['severity']; + } + + $sniffViolation = new SniffViolation($messageType, $message, $token->getLine(), $token->getFilename()); + $sniffViolation->setSeverity($severity); + $sniffViolation->setLinePosition($token->getPosition()); + + $this->getReport()->addMessage($sniffViolation); + + return $this; + } + + public function stringifyValue($token) + { + if ($token->getType() === Token::STRING_TYPE) { + return $token->getValue(); + } else { + return '\'' . $token->getValue() . '\''; + } + } +} diff --git a/src/Asm89/Twig/Lint/Sniffs/AbstractSniff.php b/src/Asm89/Twig/Lint/Sniffs/AbstractSniff.php new file mode 100644 index 0000000..40a67d9 --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/AbstractSniff.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +use Asm89\Twig\Lint\Report; + +/** + * Base for all sniff. + * + * @author Hussard + */ +abstract class AbstractSniff implements SniffInterface +{ + /** + * Default options for all sniffs. + * + * @var array + */ + protected static $defaultOptions = array( + 'severity' => self::SEVERITY_DEFAULT, + ); + + /** + * Computed options of this sniffs. + * + * @var array + */ + protected $options; + + /** + * When process is called, it will fill this report with the potential violations. + * + * @var Report + */ + protected $report; + + /** + * Constructor. + * + * @param array $options Each sniff can defined its options. + */ + public function __construct($options = array()) + { + $this->messages = array(); + $this->report = null; + $this->options = array_merge(self::$defaultOptions, $options); + + $this->configure(); + } + + /** + * Configure this sniff based on its options. + * + * @return void + */ + public function configure() + { + // Nothing. + } + + /** + * {@inheritDoc} + */ + public function enable(Report $report) + { + $this->report = $report; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function disable() + { + $this->report = null; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getReport() + { + if (null === $this->report) { + throw new \Exception('Sniff is disabled!'); + } + + return $this->report; + } + + /** + * {@inheritDoc} + */ + abstract public function getType(); +} diff --git a/src/Asm89/Twig/Lint/Sniffs/PostParserSniffInterface.php b/src/Asm89/Twig/Lint/Sniffs/PostParserSniffInterface.php new file mode 100644 index 0000000..90a0ba6 --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/PostParserSniffInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +/** + * Base for all post-parser sniff. + * + * A post parser sniff should be useful to check actual values of twig functions, filters + * and tags such as: ensure that a given function has at least 3 arguments or if the template + * contains an {% include %} tag. + * + * Use `PreParserSniffInterface` sniff if you want to check syntax and code formatting. + * + * @author Hussard + */ +interface PostParserSniffInterface extends SniffInterface +{ + public function process(\Twig_Node $node, \Twig_Environment $env); +} diff --git a/src/Asm89/Twig/Lint/Sniffs/PreParserSniffInterface.php b/src/Asm89/Twig/Lint/Sniffs/PreParserSniffInterface.php new file mode 100644 index 0000000..0049683 --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/PreParserSniffInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; + +interface PreParserSniffInterface extends SniffInterface +{ + public function process(Token $token, $tokenPosition, $stream); +} diff --git a/src/Asm89/Twig/Lint/Sniffs/SniffInterface.php b/src/Asm89/Twig/Lint/Sniffs/SniffInterface.php new file mode 100644 index 0000000..c47e69d --- /dev/null +++ b/src/Asm89/Twig/Lint/Sniffs/SniffInterface.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Sniffs; + +use Asm89\Twig\Lint\Report; + +/** + * Interface for all sniffs. + * + * @author Hussard + */ +interface SniffInterface +{ + const MESSAGE_TYPE_NOTICE = 0; + const MESSAGE_TYPE_WARNING = 1; + const MESSAGE_TYPE_ERROR = 2; + + const SEVERITY_MIN = 0; + const SEVERITY_DEFAULT = 5; + const SEVERITY_MAX = 10; + + const TYPE_PRE_PARSER = 'lint.pre_parser'; + const TYPE_POST_PARSER = 'lint.post_parser'; + + /** + * Enable the sniff. + * + * Once the sniff is enabled, it will be registered and executed when a template is tokenized or parsed. Messages + * will be added to the given `$report` object. + * + * @param Report $report + * + * @return self + */ + public function enable(Report $report); + + /** + * Disable the sniff. + * + * It usually is disabled when the processing is over, it will reset the sniff internal values for next check. + * + * @return self + */ + public function disable(); + + /** + * Get the current report. + * + * @return Report + * @throws \Exception A disabled sniff has no current report. + */ + public function getReport(); + + /** + * Get the type of sniff. + * + * @return string One of `TYPE` constants. + */ + public function getType(); +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/CheckArgumentsSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/CheckArgumentsSniff.php new file mode 100644 index 0000000..00815c4 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/CheckArgumentsSniff.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Generic sniff for checking arguments in calls. + */ +class CheckArgumentsSniff extends AbstractPostParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(\Twig_Node $node, \Twig_Environment $env) + { + foreach ($this->options['nodes'] as $search) { + if (!$this->isNodeMatching($node, $search['type'], isset($search['name']) ? $search['name'] : null)) { + continue; + } + + $arguments = $node->getNode('arguments'); + if (count($arguments) < $search['min']) { + $this->addMessage( + $this::MESSAGE_TYPE_ERROR, + sprintf( + isset($search['message']) ? $search['message'] : 'Call to %s %s() requires at least %d parameters; only %d found', + $search['type'], + isset($search['name']) ? $search['name'] : '', + $search['min'], + count($arguments) + ), + $node + ); + } + } + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DeprecatedTemplateNotationSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DeprecatedTemplateNotationSniff.php new file mode 100644 index 0000000..b0991fa --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DeprecatedTemplateNotationSniff.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Locates the template notation in include, extends and embed tags. + * + * Twig template notation has changed: + * - `AcmeBundle:Some/Controller:index.html.twig` (Symfony 2.8 or earlier, deprecated) + * - `@Acme/Some/Controller:index.html.twig` (Symfony 3+) + */ +class DeprecatedTemplateNotationSniff extends AbstractPostParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(\Twig_Node $node, \Twig_Environment $env) + { + + if ($this->isNodeMatching($node, 'tag', 'include')) { + $this->processIncludeTag($node); + } elseif ($this->isNodeMatching($node, 'function', 'include')) { + $this->processIncludeFunction($node); + } elseif ($node instanceof \Twig_Node_Module && $node->hasNode('parent')) { + $this->processExtendsTag($node); + } + + return $node; + } + + public function processIncludeTag($node) + { + $exprNode = $node->getNode('expr'); + if ($exprNode instanceof \Twig_Node_Expression_Constant) { + // Only works with constant expression and not concatenation or function calls. + $this->processTemplateFormat($exprNode->getAttribute('value'), $node); + } + } + + public function processIncludeFunction($node) + { + $arguments = $node->getNode('arguments'); + if (0 < $arguments->count() && $arguments->getNode(0) instanceof \Twig_Node_Expression_Constant) { + $this->processTemplateFormat($arguments->getNode(0)->getAttribute('value'), $node); + } + } + + public function processExtendsTag($node) + { + $parent = $node->getNode('parent'); + if ('__parent__' !== $parent->getAttribute('value')) { + $this->processTemplateFormat($parent->getAttribute('value'), $node); + } + } + + public function processTemplateFormat($templateName, $node) + { + $check = strpos($templateName, '@'); + if (false === $check || 0 !== $check) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf( + 'Deprecated template notation "%s"; use Symfony 3+ template notation with "@" instead', + $templateName + ), + $node + ); + } + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowCommentedCodeSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowCommentedCodeSniff.php new file mode 100644 index 0000000..119410a --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowCommentedCodeSniff.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Disallow keeping commented code. + * + * This will be triggered if `{{` or `{%` is found inside a comment. + */ +class DisallowCommentedCodeSniff extends AbstractPreParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::COMMENT_START_TYPE)) { + $i = $tokenPosition; + $found = false; + while ( + !$this->isTokenMatching($tokens[$i], Token::COMMENT_END_TYPE) || $this->isTokenMatching($tokens[$i], Token::EOF_TYPE)) { + if ( + $this->isTokenMatching($tokens[$i], Token::TEXT_TYPE, '{{') + || $this->isTokenMatching($tokens[$i], Token::TEXT_TYPE, '{%') + ) { + $found = true; + + break; + } + + ++$i; + } + + if ($found) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + 'Probable commented code found; keeping commented code is usually not advised', + $token + ); + } + } + + return $token; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowDumpSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowDumpSniff.php new file mode 100644 index 0000000..fe5d70b --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowDumpSniff.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Detects and complain about use of `{{ dump() }}` or `{% dump() %}`. + * + * Calls to the dump function would not appear on production but keeping calls to + * debug functions is never a good thing. + */ +class DisallowDumpSniff extends DisallowNodeSniff +{ + /** + * {@inheritdoc} + */ + public function configure() + { + $this->options['nodes'] = array( + array( + 'message' => 'Call to debug %s %s() must be removed', + 'name' => 'dump', + 'type' => 'function', + ), + array( + 'message' => 'Call to debug %s %s() must be removed', + 'name' => 'dump', + 'type' => 'tag', + ), + ); + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowIncludeTagSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowIncludeTagSniff.php new file mode 100644 index 0000000..7fd4b8a --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowIncludeTagSniff.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Detects use of `{% include %}` instead of `{{ include() }}` + * + * @see https://github.com/twigphp/Twig/issues/1899 + */ +class DisallowIncludeTagSniff extends DisallowNodeSniff +{ + /** + * {@inheritdoc} + */ + public function configure() + { + $this->options['nodes'] = array( + array( + 'message' => 'Include tag is deprecated; use the include() function instead', + 'name' => 'include', + 'type' => 'tag', + ), + ); + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowNodeSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowNodeSniff.php new file mode 100644 index 0000000..33a0630 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowNodeSniff.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Generic sniff to find and disallow certain type of functions, filters or tags. + * + * Use options to set what this sniff is looking for. + */ +class DisallowNodeSniff extends AbstractPostParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(\Twig_Node $node, \Twig_Environment $env) + { + if (!isset($this->options['nodes']) || empty($this->options['nodes'])) { + return; + } + + foreach ($this->options['nodes'] as $search) { + $name = isset($search['name']) ? $search['name'] : null; + + if ($this->isNodeMatching($node, $search['type'], $name)) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf( + isset($search['message']) ? $search['message'] : 'Call to %s %s() must be removed', + $search['type'], + $name ?: '' + ), + $node + ); + } + } + + return $node; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowTabIndentSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowTabIndentSniff.php new file mode 100644 index 0000000..55baefb --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/DisallowTabIndentSniff.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Disallow the use of tabs to indent code. + */ +class DisallowTabIndentSniff extends AbstractPreParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::TAB_TYPE)) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + 'Indentation using tabs is not allowed; use spaces instead', + $token + ); + } + + return $token; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureBlankAtEOFSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureBlankAtEOFSniff.php new file mode 100644 index 0000000..dcb8ec7 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureBlankAtEOFSniff.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Ensure that files ends with one blank line. + */ +class EnsureBlankAtEOFSniff extends AbstractPreParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::EOF_TYPE)) { + $i = 0; + while ( + isset($tokens[$tokenPosition - ($i + 1)]) + && $this->isTokenMatching($tokens[$tokenPosition - ($i + 1)], Token::EOL_TYPE) + ) { + ++$i; + } + + if (1 !== $i) { + // Either 0 or 2+ blank lines. + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('A file must end with 1 blank line; found %d', $i), + $token + ); + } + + + } + + return $token; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashKeyQuotesSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashKeyQuotesSniff.php new file mode 100644 index 0000000..e96ebea --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashKeyQuotesSniff.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +class EnsureHashKeyQuotesSniff extends AbstractPreParserSniff +{ + protected $processedToken; + + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ( + !$this->isTokenMatching($token, Token::PUNCTUATION_TYPE, '{') + || (null !== $this->getProcessedToken() + && $this->getProcessedToken() >= $tokenPosition) + ) { + return $token; + } + + list($startPosition, $endPosition) = $this->findHashPositions($tokenPosition, $tokens); + + $j = $startPosition + 1; + while ($j < $endPosition) { + if ( + !$this->isTokenMatching($tokens[$j], Token::WHITESPACE_TYPE) + && !$this->isTokenMatching($tokens[$j], Token::EOL_TYPE) + ) { + $keyTokens = array(); + while (!$this->isTokenMatching($tokens[$j], Token::PUNCTUATION_TYPE, ':') && $j < $endPosition) { + $keyTokens[] = $tokens[$j]; + ++$j; + } + + + // Not a string with quotes ? + if (1 === count($keyTokens) && !$this->isTokenMatching($keyTokens[0], Token::STRING_TYPE)) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Hash key \'%s\' requires quotes; use single quotes', $keyTokens[0]->getValue()), + $keyTokens[0] + ); + } + + // Skip until the end of the key: value pair eg. `,` or the start of a sub-hash eg. `{`. + while ( + !$this->isTokenMatching($tokens[$j], Token::PUNCTUATION_TYPE, ',') + && !$this->isTokenMatching($tokens[$j], Token::PUNCTUATION_TYPE, '{') + && $j < $endPosition + ) { + ++$j; + } + } + + ++$j; + } + + $this->setProcessedToken($endPosition); + + return $token; + } + + public function findHashPositions($tokenPosition, $tokens) + { + $hashStarts = $hashEnds = array(); + + $hashStarts[] = $tokenPosition; + + $i = $tokenPosition + 1; + while (count($tokens) > $i && count($hashStarts) > count($hashEnds)) { + if ($this->isTokenMatching($tokens[$i], Token::PUNCTUATION_TYPE, '{')) { + array_push($hashStarts, $i); + } + + if ($this->isTokenMatching($tokens[$i], Token::PUNCTUATION_TYPE, '}')) { + array_unshift($hashEnds, $i); + } + + ++$i; + } + + return array($hashStarts[0], $hashEnds[count($hashEnds) - 1]); + } + + public function setProcessedToken($processedToken) + { + $this->processedToken = $processedToken; + } + + public function getProcessedToken() + { + return $this->processedToken; + } + + public function disable() + { + parent::disable(); + + $this->processedToken = null; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashSpacingSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashSpacingSniff.php new file mode 100644 index 0000000..398daf4 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashSpacingSniff.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Ensure that the number of space is correct for a hash, eg. any after + * `{` or `[` and before `}` or `]`. + * + * By default, those are correct: + * + * {% set hash = {x: 1, y: 2, z: 3} %} + * + * {% set hash = { + * x: 1, + * y: 2, + * z: 3, + * } %} + * + * You can change the number of space wanted using the `count` option. + */ +class EnsureHashSpacingSniff extends AbstractPreParserSniff +{ + /** + * Number of whitespace expected before/after an expression. + * + * @var int + */ + protected $expected; + + /** + * {@inheritdoc} + */ + public function configure() + { + $this->expected = 0; + if (isset($this->options['count'])) { + $this->expected = $this->options['count']; + } + } + + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::PUNCTUATION_TYPE, '{') || $this->isTokenMatching($token, Token::PUNCTUATION_TYPE, '[')) { + $this->processStart($token, $tokenPosition, $tokens); + } + + if ($this->isTokenMatching($token, Token::PUNCTUATION_TYPE, '}') || $this->isTokenMatching($token, Token::PUNCTUATION_TYPE, ']')) { + $this->processEnd($token, $tokenPosition, $tokens); + } + + return $token; + } + + public function processStart(Token $token, $tokenPosition, $tokens) + { + $offset = 1; + while ( + $this->isTokenMatching($tokens[$tokenPosition + $offset], Token::WHITESPACE_TYPE) + && !$this->isTokenMatching($tokens[$tokenPosition + $offset], Token::EOL_TYPE) + ) { + ++$offset; + } + + $count = $offset - 1; + if ($this->expected !== $count) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Expecting %d whitespace AFTER "%s"; found %d', $this->expected, $token->getValue(), $count), + $token + ); + } + } + + public function processEnd(Token $token, $tokenPosition, $tokens) + { + $offset = 1; + while ( + $this->isTokenMatching($tokens[$tokenPosition - $offset], Token::WHITESPACE_TYPE) + && !$this->isTokenMatching($tokens[$tokenPosition - $offset], Token::EOL_TYPE) + ) { + ++$offset; + } + + if ($this->isTokenMatching($tokens[$tokenPosition - $offset], Token::EOL_TYPE)) { + // Reached the start of the line with only space, don't complain about that, that's only indent. + return; + } + + $count = $offset - 1; + if ($this->expected !== $count) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Expecting %d whitespace BEFORE "%s"; found %d', $this->expected, $token->getValue(), $count), + $token + ); + } + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashTrailingCommaSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashTrailingCommaSniff.php new file mode 100644 index 0000000..5d5714d --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureHashTrailingCommaSniff.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +class EnsureHashTrailingCommaSniff extends AbstractPreParserSniff +{ + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::PUNCTUATION_TYPE, '}')) { + $i = $tokenPosition - 1; + while ($this->isTokenMatching($tokens[$i], Token::WHITESPACE_TYPE) || $this->isTokenMatching($tokens[$i], Token::EOL_TYPE)) { + --$i; + } + + if (1 < ($tokenPosition - $i) && !$this->isTokenMatching($tokens[$i], Token::PUNCTUATION_TYPE, ',')) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Hash requires trailing comma after %s', $this->stringifyValue($tokens[$i])), + $tokens[$i] + ); + } + } + + return $token; + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureQuotesStyleSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureQuotesStyleSniff.php new file mode 100644 index 0000000..5548f99 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureQuotesStyleSniff.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Check a style of quotes for strings (single or double quotes). + * + * This will not ensure that quotes are present and will only check that quotes + * being used match the right style. + */ +class EnsureQuotesStyleSniff extends AbstractPreParserSniff +{ + /** + * {@inheritdoc} + */ + public function configure() + { + // Default: 'TYPE_SINGLE_QUOTES' + $this->disallowedQuoteChar = '"'; + $this->violationMsg = 'String %s does not require double quotes; use single quotes instead'; + + if (isset($this->options['style']) && 'TYPE_DOUBLE_QUOTES' === $this->options['style']) { + $this->disallowedQuoteChar = '\''; + $this->violationMsg = 'String %s uses single quotes; use double quotes instead'; + } + } + + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::STRING_TYPE)) { + $value = $token->getValue(); + if ($this->disallowedQuoteChar === $value[0] || $this->disallowedQuoteChar === $value[strlen($value) - 1]) { + $this->addMessage($this::MESSAGE_TYPE_WARNING, sprintf($this->violationMsg, $this->stringifyValue($token)), $token); + } + } + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureTranslationArgumentsSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureTranslationArgumentsSniff.php new file mode 100644 index 0000000..efdc8ac --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureTranslationArgumentsSniff.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Sniffs\AbstractPostParserSniff; + +/** + * Ensure that `|trans()` and `|transchoice()` have all their arguments. + */ +class EnsureTranslationArgumentsSniff extends CheckArgumentsSniff +{ + /** + * {@inheritdoc} + */ + public function configure() + { + $this->options['nodes'] = array( + array( + 'message' => 'Call to %s %s() requires parameter "domain"; expected %d parameters, found %d', + 'min' => 2, + 'name' => 'trans', + 'type' => 'filter', + ), + array( + 'message' => 'Call to %s %s() requires parameter "lang"; expected %d parameters, found %d', + 'min' => 3, + 'name' => 'trans', + 'type' => 'filter', + ), + array( + 'message' => 'Call to %s %s() requires parameter "domain"; expected %d parameters, found %d', + 'min' => 3, + 'name' => 'transchoice', + 'type' => 'filter', + ), + array( + 'message' => 'Call to %s %s() requires parameter "lang"; expected %d parameters, found %d', + 'min' => 4, + 'name' => 'transchoice', + 'type' => 'filter', + ), + ); + } +} diff --git a/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureWhitespaceExpressionSniff.php b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureWhitespaceExpressionSniff.php new file mode 100644 index 0000000..18fc457 --- /dev/null +++ b/src/Asm89/Twig/Lint/Standards/Generic/Sniffs/EnsureWhitespaceExpressionSniff.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Standards\Generic\Sniffs; + +use Asm89\Twig\Lint\Tokenizer\Token; +use Asm89\Twig\Lint\Sniffs\AbstractPreParserSniff; + +/** + * Ensure that the number of space is correct before and after an expression, eg. + * any after `{{` or `{%` and before `}}` or `%}`. + */ +class EnsureWhitespaceExpressionSniff extends AbstractPreParserSniff +{ + /** + * Number of whitespace expected before/after an expression. + * + * @var int + */ + protected $expected; + + /** + * {@inheritdoc} + */ + public function configure() + { + $this->expected = 1; + if (isset($this->options['count'])) { + $this->expected = $this->options['count']; + } + } + + /** + * {@inheritdoc} + */ + public function process(Token $token, $tokenPosition, $tokens) + { + if ($this->isTokenMatching($token, Token::VAR_START_TYPE) || $this->isTokenMatching($token, Token::BLOCK_START_TYPE)) { + $this->processStart($token, $tokenPosition, $tokens); + } + + if ($this->isTokenMatching($token, Token::VAR_END_TYPE) || $this->isTokenMatching($token, Token::BLOCK_END_TYPE)) { + $this->processEnd($token, $tokenPosition, $tokens); + } + + return $token; + } + + public function processStart(Token $token, $tokenPosition, $tokens) + { + $offset = 1; + while ( + $this->isTokenMatching($tokens[$tokenPosition + $offset], Token::WHITESPACE_TYPE) + || $this->isTokenMatching($tokens[$tokenPosition + $offset], Token::EOL_TYPE) + ) { + ++$offset; + } + + $count = $offset - 1; + if ($this->expected !== $count) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Expecting %d whitespace AFTER start of expression eg. "{{" or "{%%"; found %d', $this->expected, $count), + $token + ); + } + } + + public function processEnd(Token $token, $tokenPosition, $tokens) + { + $offset = 1; + while ( + $this->isTokenMatching($tokens[$tokenPosition - $offset], Token::WHITESPACE_TYPE) + || $this->isTokenMatching($tokens[$tokenPosition - $offset], Token::EOL_TYPE) + ) { + ++$offset; + } + + $count = $offset - 1; + if ($this->expected !== $count) { + $this->addMessage( + $this::MESSAGE_TYPE_WARNING, + sprintf('Expecting %d whitespace BEFORE end of expression eg. "}}" or "%%}"; found %d', $this->expected, $count), + $token + ); + } + } +} diff --git a/src/Asm89/Twig/Lint/StubbedEnvironment.php b/src/Asm89/Twig/Lint/StubbedEnvironment.php index a84d146..9ceb00d 100644 --- a/src/Asm89/Twig/Lint/StubbedEnvironment.php +++ b/src/Asm89/Twig/Lint/StubbedEnvironment.php @@ -11,7 +11,8 @@ namespace Asm89\Twig\Lint; -use Asm89\Twig\Lint\Extension\StubbedCore; +use Asm89\Twig\Lint\Extension\SniffsExtension; +use Asm89\Twig\Lint\TokenParser\CatchAll; use Twig_LoaderInterface; /** @@ -25,7 +26,7 @@ class StubbedEnvironment extends \Twig_Environment private $stubFilters; private $stubFunctions; private $stubTests; - protected $parsers; + private $stubCallable; /** * {@inheritDoc} @@ -34,11 +35,27 @@ public function __construct(Twig_LoaderInterface $loader = null, $options = arra { parent::__construct($loader, $options); - $this->addExtension(new StubbedCore()); - $this->initExtensions(); + $this->stubCallable = function () { + /* This will be used as stub filter, function or test */ + }; - $broker = new StubbedTokenParserBroker(); - $this->parsers->addTokenParserBroker($broker); + $this->stubFilters = array(); + $this->stubFunctions = array(); + + if (isset($options['stub_tags'])) { + foreach ($options['stub_tags'] as $tag) { + $this->addTokenParser(new CatchAll($tag)); + } + } + + $this->stubTests = array(); + if (isset($options['stub_tests'])) { + foreach ($options['stub_tests'] as $test) { + $this->stubTests[$test] = new \Twig_SimpleTest('stub', $this->stubCallable); + } + } + + $this->addExtension(new SniffsExtension()); } /** @@ -47,7 +64,7 @@ public function __construct(Twig_LoaderInterface $loader = null, $options = arra public function getFilter($name) { if (!isset($this->stubFilters[$name])) { - $this->stubFilters[$name] = new \Twig_Filter_Function('stub'); + $this->stubFilters[$name] = new \Twig_SimpleFilter('stub', $this->stubCallable); } return $this->stubFilters[$name]; @@ -59,7 +76,7 @@ public function getFilter($name) public function getFunction($name) { if (!isset($this->stubFunctions[$name])) { - $this->stubFunctions[$name] = new \Twig_Function_Function('stub'); + $this->stubFunctions[$name] = new \Twig_SimpleFunction('stub', $this->stubCallable); } return $this->stubFunctions[$name]; @@ -70,10 +87,15 @@ public function getFunction($name) */ public function getTest($name) { - if (!isset($this->stubTests[$name])) { - $this->stubTests[$name] = new \Twig_SimpleTest('stub', function(){}); + $test = parent::getTest($name); + if ($test) { + return $test; + } + + if (isset($this->stubTests[$name])) { + return $this->stubTests[$name]; } - return $this->stubTests[$name]; + return false; } } diff --git a/src/Asm89/Twig/Lint/StubbedTokenParserBroker.php b/src/Asm89/Twig/Lint/StubbedTokenParserBroker.php deleted file mode 100644 index 95042d6..0000000 --- a/src/Asm89/Twig/Lint/StubbedTokenParserBroker.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Asm89\Twig\Lint; - -use Asm89\Twig\Lint\TokenParser\CatchAll; -use Twig_TokenParserBroker; - -/** - * Broker providing stubs for all tags that are not defined. - * - * @author Alexander - */ -class StubbedTokenParserBroker extends Twig_TokenParserBroker -{ - protected $parser; - protected $parsers; - - /** - * {@inheritDoc} - */ - public function getTokenParser($name) - { - if (!isset($this->parsers[$name])) { - $this->parsers[$name] = new CatchAll($name); - $this->parsers[$name]->setParser($this->parser); - } - - return $this->parsers[$name]; - } - - /** - * {@inheritDoc} - */ - public function getParser() - { - return $this->parser; - } - - /** - * {@inheritDoc} - */ - public function setParser(\Twig_ParserInterface $parser) - { - $this->parser = $parser; - } -} diff --git a/src/Asm89/Twig/Lint/TokenParser/CatchAll.php b/src/Asm89/Twig/Lint/TokenParser/CatchAll.php index d69e5da..8cb5ce0 100644 --- a/src/Asm89/Twig/Lint/TokenParser/CatchAll.php +++ b/src/Asm89/Twig/Lint/TokenParser/CatchAll.php @@ -58,7 +58,12 @@ public function parse(Twig_Token $token) $stream->expect(Twig_Token::BLOCK_END_TYPE); } - return null; + $attributes = array(); + if ($token->getValue()) { + $attributes['name'] = $token->getValue(); + } + + return new \Twig_Node(array(), $attributes, $token->getLine(), $token->getValue() ?: null); } /** diff --git a/src/Asm89/Twig/Lint/Tokenizer/Token.php b/src/Asm89/Twig/Lint/Tokenizer/Token.php new file mode 100644 index 0000000..a2c1310 --- /dev/null +++ b/src/Asm89/Twig/Lint/Tokenizer/Token.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Tokenizer; + +/** + * Represents a token from a twig template. + * + * This is inspired by \Twig_Token but this is not meant to be an exact match. + * + * @author Hussard + */ +class Token +{ + const EOF_TYPE = -1; + const TEXT_TYPE = 0; + const BLOCK_START_TYPE = 1; + const VAR_START_TYPE = 2; + const BLOCK_END_TYPE = 3; + const VAR_END_TYPE = 4; + const NAME_TYPE = 5; + const NUMBER_TYPE = 6; + const STRING_TYPE = 7; + const OPERATOR_TYPE = 8; + const PUNCTUATION_TYPE = 9; + const INTERPOLATION_START_TYPE = 10; + const WHITESPACE_TYPE = 12; + const TAB_TYPE = 13; + const EOL_TYPE = 14; + const COMMENT_START_TYPE = 15; + const COMMENT_END_TYPE = 16; + + public function __construct($type, $lineno, $position, $filename, $value = null) + { + $this->type = $type; + $this->lineno = $lineno; + $this->position = $position; + $this->filename = $filename; + $this->value = $value; + } + + public function getType() + { + return $this->type; + } + + public function getLine() + { + return $this->lineno; + } + + public function getFilename() + { + return $this->filename; + } + + public function getPosition() + { + return $this->position; + } + + public function getValue() + { + return $this->value; + } +} diff --git a/src/Asm89/Twig/Lint/Tokenizer/Tokenizer.php b/src/Asm89/Twig/Lint/Tokenizer/Tokenizer.php new file mode 100644 index 0000000..0556cae --- /dev/null +++ b/src/Asm89/Twig/Lint/Tokenizer/Tokenizer.php @@ -0,0 +1,381 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Tokenizer; + +/** + * This tokenizer is a retake on the Twig_Lexer that will tokenize a given template + * with + * + * @author Hussard + */ +class Tokenizer implements TokenizerInterface +{ + const STATE_DATA = 0; + const STATE_BLOCK = 1; + const STATE_VAR = 2; + const STATE_COMMENT = 3; + + const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A'; + const REGEX_NUMBER = '/[0-9]+(?:\.[0-9]+)?/A'; + const REGEX_STRING = '/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As'; + const REGEX_DQ_STRING_DELIM = '/"/A'; + const REGEX_DQ_STRING_PART = '/[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*/As'; + const PUNCTUATION = '()[]{}?:.,|'; + + protected $env; + + protected $options; + + protected $regexes; + + public function __construct(\Twig_Environment $env, array $options = array()) + { + $this->env = $env; + + $this->regexes = array(); + + $this->options = array_merge(array( + 'tag_comment' => array('{#', '#}'), + 'tag_block' => array('{%', '%}'), + 'tag_variable' => array('{{', '}}'), + 'whitespace_trim' => '-', + 'interpolation' => array('#{', '}'), + ), $options); + + $this->regexes['tokens_start'] = '/('.preg_quote($this->options['tag_variable'][0], '/').'|'.preg_quote($this->options['tag_block'][0], '/').'|'.preg_quote($this->options['tag_comment'][0], '/').')('.preg_quote($this->options['whitespace_trim'], '/').')?/s'; + $this->regexes['lex_block'] = '/('.preg_quote($this->options['whitespace_trim']).')?('.preg_quote($this->options['tag_block'][1]).')/'; + $this->regexes['lex_variable'] = '/('.preg_quote($this->options['whitespace_trim']).')?('.preg_quote($this->options['tag_variable'][1]).')/'; + $this->regexes['lex_comment'] = '/('.preg_quote($this->options['whitespace_trim']).')?('.preg_quote($this->options['tag_comment'][1]).')/'; + $this->regexes['operator'] = $this->getOperatorRegex(); + } + + protected function resetState() + { + $this->cursor = 0; + $this->lineno = 1; + $this->currentPosition = 0; + $this->tokens = array(); + $this->state = array(); + } + + protected function preflightSource($code) + { + $tokenPositions = array(); + preg_match_all($this->regexes['tokens_start'], $code, $tokenPositions, PREG_OFFSET_CAPTURE); + + $tokenPositionsReworked = array(); + foreach ($tokenPositions[0] as $index => $tokenFullMatch) { + $tokenPositionsReworked[$index] = array( + 'fullMatch' => $tokenFullMatch[0], + 'position' => $tokenFullMatch[1], + 'match' => $tokenPositions[1][$index][0], + ); + } + + return $tokenPositionsReworked; + } + + protected function moveCurrentPosition($value = 1) + { + $this->currentPosition += $value; + } + + protected function moveCursor($value) + { + $this->cursor += strlen($value); + $this->lineno += substr_count($value, "\n"); + } + + protected function getTokenPosition($tokenPosition = null) + { + if (null === $tokenPosition) { + $tokenPosition = $this->currentPosition; + } + + if (empty($this->tokenPositions)) { + // No token at all found during preflight. + return null; + } + + if (!isset($this->tokenPositions[$this->currentPosition])) { + // No token for current position. + return null; + } + + return $this->tokenPositions[$this->currentPosition]; + } + + protected function pushToken($type, $value = null) + { + $tokenPositionInLine = $this->cursor - strrpos(substr($this->code, 0, $this->cursor), PHP_EOL); + + $this->tokens[] = new Token($type, $this->lineno, $tokenPositionInLine, $this->filename, $value); + } + + protected function getState() + { + return !empty($this->state) ? $this->state[count($this->state) - 1]: self::STATE_DATA; + } + + protected function pushState($state) + { + $this->state[] = $state; + } + + protected function popState() + { + if (0 === count($this->state)) { + throw new \Exception('Cannot pop state without a previous state'); + } + + array_pop($this->state); + } + + public function tokenize(\Twig_Source $source) + { + // Reset everything. + $this->resetState(); + + $this->code = $source->getCode(); + $this->end = strlen($this->code); + $this->filename = $source->getName(); + + // Preflight source code for token positions. + $this->tokenPositions = $this->preflightSource($this->code); + while ($this->cursor < $this->end) { + $nextToken = $this->getTokenPosition(); + + switch ($this->getState()) { + case self::STATE_BLOCK: + $this->lexBlock(); + + break; + case self::STATE_VAR: + $this->lexVariable(); + + break; + case self::STATE_COMMENT: + $this->lexComment(); + + break; + case self::STATE_DATA: + default: + if ($this->cursor === $nextToken['position']) { + $this->lexStart(); + } else { + $this->lexData(); + } + break; + } + } + + if (self::STATE_DATA !== $this->getState()) { + throw new \Exception('Error Processing Request', 1); + } + + $this->pushToken(Token::EOF_TYPE); + + return $this->tokens; + } + + protected function lex($endType, $end, $endRegex) + { + preg_match($endRegex, $this->code, $match, PREG_OFFSET_CAPTURE, $this->cursor); + if (!isset($match[0])) { + // Should not happen, but in case it is; + throw new \Exception(sprintf('Unclosed "%s" in "%s" at line %d', $endType, $this->filename, $this->lineno)); + } + + if ($match[0][1] === $this->cursor) { + $this->pushToken($endType, $match[0][0]); + $this->moveCursor($match[0][0]); + $this->moveCurrentPosition(); + $this->popState(); + } else { + if ($this->getState() === self::STATE_COMMENT) { + // Parse as text until the end position. + $this->lexData($match[0][1]); + } else { + while ($this->cursor < $match[0][1]) { + $this->lexExpression(); + } + } + } + } + + protected function lexExpression() + { + $currentToken = $this->code[$this->cursor]; + if (' ' === $currentToken) { + $this->lexWhitespace(); + } elseif (PHP_EOL === $currentToken) { + $this->lexEOL(); + } elseif (preg_match($this->regexes['operator'], $this->code, $match, null, $this->cursor)) { + // operators + $this->pushToken(Token::OPERATOR_TYPE, $match[0]); + $this->moveCursor($match[0]); + } elseif (preg_match(self::REGEX_NAME, $this->code, $match, null, $this->cursor)) { + // names + $this->pushToken(Token::NAME_TYPE, $match[0]); + $this->moveCursor($match[0]); + } elseif (preg_match(self::REGEX_NUMBER, $this->code, $match, null, $this->cursor)) { + // numbers + $number = (float) $match[0]; // floats + if (ctype_digit($match[0]) && $number <= PHP_INT_MAX) { + $number = (int) $match[0]; // integers lower than the maximum + } + $this->pushToken(Token::NUMBER_TYPE, $number); + $this->moveCursor($match[0]); + } elseif (false !== strpos(self::PUNCTUATION, $this->code[$this->cursor])) { + // punctuation + if (false !== strpos('([{', $this->code[$this->cursor])) { + // opening bracket + $this->brackets[] = array($this->code[$this->cursor], $this->lineno); + } elseif (false !== strpos(')]}', $this->code[$this->cursor])) { + // closing bracket + if (empty($this->brackets)) { + throw new \Exception(sprintf('Unexpected "%s".', $this->code[$this->cursor])); + } + + list($expect, $lineno) = array_pop($this->brackets); + if ($this->code[$this->cursor] != strtr($expect, '([{', ')]}')) { + throw new \Exception(sprintf('Unclosed "%s".', $expect)); + } + } + + $this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]); + $this->moveCursor($this->code[$this->cursor]); + } elseif (preg_match(self::REGEX_STRING, $this->code, $match, null, $this->cursor)) { + // strings + $this->pushToken(Token::STRING_TYPE, stripcslashes($match[0])); + $this->moveCursor($match[0]); + } else { + // unlexable + throw new \Exception(sprintf('Unexpected character "%s".', $this->code[$this->cursor])); + } + } + + protected function lexBlock() + { + $this->lex(Token::BLOCK_END_TYPE, $this->options['tag_block'][1], $this->regexes['lex_block']); + } + + protected function lexVariable() + { + $this->lex(Token::VAR_END_TYPE, $this->options['tag_variable'][1], $this->regexes['lex_variable']); + } + + protected function lexComment() + { + $this->lex(Token::COMMENT_END_TYPE, $this->options['tag_comment'][1], $this->regexes['lex_comment']); + } + + protected function lexData($limit = 0) + { + $nextToken = $this->getTokenPosition(); + if (0 === $limit && null !== $nextToken) { + $limit = $nextToken['position']; + } + + $currentToken = $this->code[$this->cursor]; + if (preg_match('/\t/', $currentToken)) { + $this->lexTab(); + } elseif (' ' === $currentToken) { + $this->lexWhitespace(); + } elseif (PHP_EOL === $currentToken) { + $this->lexEOL(); + } elseif (preg_match('/\S+/', $this->code, $match, null, $this->cursor)) { + $value = $match[0]; + + // Stop if cursor reaches the next token start. + if (0 !== $limit && $limit <= ($this->cursor + strlen($value))) { + $value = substr($value, 0, $limit - $this->cursor); + } + + // Fixing token start among expressions and comments. + $nbTokenStart = preg_match_all($this->regexes['tokens_start'], $value, $matches); + if ($nbTokenStart) { + $this->moveCurrentPosition($nbTokenStart); + } + + $this->pushToken(Token::TEXT_TYPE, $value); + $this->moveCursor($value); + } + } + + protected function lexStart() + { + $tokenStart = $this->getTokenPosition(); + if ($tokenStart['match'] === $this->options['tag_comment'][0]) { + $state = self::STATE_COMMENT; + $tokenType = Token::COMMENT_START_TYPE; + } elseif ($tokenStart['match'] === $this->options['tag_block'][0]) { + $state = self::STATE_BLOCK; + $tokenType = Token::BLOCK_START_TYPE; + } elseif ($tokenStart['match'] === $this->options['tag_variable'][0]) { + $state = self::STATE_VAR; + $tokenType = Token::VAR_START_TYPE; + } + + $this->pushToken($tokenType, $tokenStart['fullMatch']); + $this->pushState($state); + $this->moveCursor($tokenStart['fullMatch']); + } + + protected function lexTab() + { + $this->pushToken(Token::TAB_TYPE); + $this->moveCursor($this->code[$this->cursor]); + } + + protected function lexWhitespace() + { + $this->pushToken(Token::WHITESPACE_TYPE, $this->code[$this->cursor]); + $this->moveCursor($this->code[$this->cursor]); + } + + protected function lexEOL() + { + $this->pushToken(Token::EOL_TYPE, $this->code[$this->cursor]); + $this->moveCursor($this->code[$this->cursor]); + } + + protected function getOperatorRegex() + { + $operators = array_merge( + array('='), + array_keys($this->env->getUnaryOperators()), + array_keys($this->env->getBinaryOperators()) + ); + + $operators = array_combine($operators, array_map('strlen', $operators)); + arsort($operators); + + $regex = array(); + foreach ($operators as $operator => $length) { + // an operator that ends with a character must be followed by + // a whitespace or a parenthesis + if (ctype_alpha($operator[$length - 1])) { + $r = preg_quote($operator, '/').'(?=[\s()])'; + } else { + $r = preg_quote($operator, '/'); + } + + // an operator with a space can be any amount of whitespaces + $r = preg_replace('/\s+/', '\s+', $r); + + $regex[] = $r; + } + + return '/'.implode('|', $regex).'/A'; + } +} diff --git a/src/Asm89/Twig/Lint/Tokenizer/TokenizerInterface.php b/src/Asm89/Twig/Lint/Tokenizer/TokenizerInterface.php new file mode 100644 index 0000000..2334cb6 --- /dev/null +++ b/src/Asm89/Twig/Lint/Tokenizer/TokenizerInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Tokenizer; + +/** + * Interface for a tokenizer. + * + * @author Hussard + */ +interface TokenizerInterface +{ + public function tokenize(\Twig_Source $code); +} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_1.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_1.twig new file mode 100644 index 0000000..161a015 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_1.twig @@ -0,0 +1,3 @@ +
+ {# This is a comment #} +
diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_2.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_2.twig new file mode 100644 index 0000000..915ec19 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/comment_2.twig @@ -0,0 +1,3 @@ +
+ {# {% set var = 'This is a comment' %} #} +
diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_function.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_function.twig new file mode 100644 index 0000000..6604b57 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_function.twig @@ -0,0 +1 @@ +{{ dump('oyo') }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_tag.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_tag.twig new file mode 100644 index 0000000..112101b --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/dump_tag.twig @@ -0,0 +1 @@ +{% dump('oyo') %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/embed_tag.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/embed_tag.twig new file mode 100644 index 0000000..212710d --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/embed_tag.twig @@ -0,0 +1,16 @@ +{% embed 'AcmeOyoBundle:Front:index.html.twig' with { + param1: 'AcmeOyoBundle:Front:index.html.twig' +} %} +{% endembed %} + +{% embed ['@AcmeOyoBundle/Front/index.html.twig', 'AcmeOyoBundle/Front/index_legacy.html.twig'] %} + {% block some_block %} + Oyo! + {% endblock some_block %} +{% endembed %} + +{% embed 'AcmeOyoBundle/Front/index@legacy.html.twig' %} + {% block some_block %} + Oyo2! + {% endblock some_block %} +{% endembed %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/empty.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/empty.twig new file mode 100644 index 0000000..e69de29 diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_0.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_0.twig new file mode 100644 index 0000000..d51055d --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_0.twig @@ -0,0 +1 @@ +{{ 'No blank line at EOF' }} \ No newline at end of file diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_2.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_2.twig new file mode 100644 index 0000000..09e0c9d --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_2.twig @@ -0,0 +1,3 @@ +{{ 'v--- Too much blank lines at EOF!' }} + + diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_3.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_3.twig new file mode 100644 index 0000000..34a4259 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/eof_3.twig @@ -0,0 +1,6 @@ + +{% set message = 'Whitespace at EOF' %} + +{{ message }} + + \ No newline at end of file diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/error_1.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/error_1.twig new file mode 100644 index 0000000..c9ea40a --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/error_1.twig @@ -0,0 +1 @@ +{{ (false ? '<- this is not compiling because of the parenthesis': '' }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/extends_tag.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/extends_tag.twig new file mode 100644 index 0000000..cf1cadb --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/extends_tag.twig @@ -0,0 +1,5 @@ +{% extends 'AcmeOyoBundle:Front:extend.html.twig' %} + +{% block some_block %} + Oyo! +{% endblock some_block %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_1.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_1.twig new file mode 100644 index 0000000..f3e21e7 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_1.twig @@ -0,0 +1,8 @@ +{% set hash = { + 4: 'oyo', + isX: some.attribute, + isY: true, + 'a': some.anotherAttribute|replace({'%text%': 'Hello World!'}), + (b ~ ''): 42, + ('' ~ c): 'azerty' +} %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_2.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_2.twig new file mode 100644 index 0000000..a49849e --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_2.twig @@ -0,0 +1,5 @@ +{% include "@Some/_template.html.twig" with {is_true: true, display_errors: false} %} +{{ translationKey|trans({ + + name: user.firstname ~ ' ' ~ user.lastname +}) }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_3.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_3.twig new file mode 100644 index 0000000..86439c4 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_3.twig @@ -0,0 +1,2 @@ +{# There's no hash in this file even with this -> {a: 42, b #} +{%- set a = 42 -%} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_4.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_4.twig new file mode 100644 index 0000000..323ae04 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/hash_4.twig @@ -0,0 +1,32 @@ +{% set settings = { + 'website.index': 'https://website.com', + 'client.brand_color': '#fab615', + 'keyName': 'some_key', +} %} + +{% set deep = { + 'lvl0': [ + { + lvl1_x: 89, + lvl1_y: 45.5 + }, + { + (attribute(settings, 'keyName') ~ ''): 'some_value', + (someVar): 'andThenSome', + }, + { + 'lvl1_x': 120.12, + lvl1_y: 344.56, + 'lvl2': { + lvl2_x: 150, + 'lvl2_y': false, + 'lvl3': { + 'lvl3_x': false, + 'lvl3_y': false, + lvl3_z: true, + } + }, + 'lvl1_z': 'Oyoyo' + } + ] +} %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_function.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_function.twig new file mode 100644 index 0000000..c2a929d --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_function.twig @@ -0,0 +1,3 @@ +{{ include('AcmeOyoBundle:Front:index.html.twig') }} +{{ include(someVar.bundle ~ '/Front/index.html.twig') }} +{{ include([someVar.bundle ~ '/Front/index.html.twig', 'AcmeOyoBundle/Front/index.html.twig']) }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_no.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_no.twig new file mode 100644 index 0000000..105d4f5 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_no.twig @@ -0,0 +1,5 @@ +{{ include() }} +{{ include('') }} +{{ include(null) }} +{{ include(false) }} +{{ include([]) }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_tag.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_tag.twig new file mode 100644 index 0000000..03ad175 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/include_tag.twig @@ -0,0 +1,8 @@ +{% include 'AcmeOyoBundle:Front:index.html.twig' with { + 'x': 1, + 'y': 2, + 'z': false, +} %} +{% include 'AcmeOyoBundle:Front:index@legacy.html.twig' %} +{% include ['@AcmeOyoBundle/Front/index.html.twig', 'AcmeOyoBundle/Front/index_legacy.html.twig'] %} +{% include '@AcmeOyoBundle/Front/' ~ type.slug ~ '/index.html.twig' with {} only %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/quotes_1.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/quotes_1.twig new file mode 100644 index 0000000..73ba044 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/quotes_1.twig @@ -0,0 +1,16 @@ +{% extends "@Acme/Some/base.html.twig" %} + +{% block body %} + This should not be picked up: "Welcome!" + +
    + {% for items in items if item.type is sameas('TYPE_1') %} +
  • + {% set value = attribute(controllerValues, "some")|default("default") %} + {% if value == "some_value" %} + {{ ("Value: " ~ controllerValue ~ ".")|trans({}, "front", lang) }} + {% endif %} +
  • + {% endfor %} +
+{% endblock %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/same_as.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/same_as.twig new file mode 100644 index 0000000..1480a9a --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/same_as.twig @@ -0,0 +1,11 @@ +{% if false is same as(false) %} + {{ 'Print me this' }} +{% endif %} + +{% if 10 is divisible by(2) %} + {{ 'divisible by 2 alright' }} +{% endif %} + +{% if true is same as(true) %} + {{ 'Print me this' }} +{% endif %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans.twig new file mode 100644 index 0000000..1c69306 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans.twig @@ -0,0 +1 @@ +{{ 'oyo'|trans({}, 'front') }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans_no.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans_no.twig new file mode 100644 index 0000000..76081ae --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/trans_no.twig @@ -0,0 +1 @@ +{{ 'oyo'|trans }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/transchoice.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/transchoice.twig new file mode 100644 index 0000000..0498edd --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/transchoice.twig @@ -0,0 +1 @@ +{{ 'oyo'|transchoice(42, {}, 'front') }} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_1.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_1.twig new file mode 100644 index 0000000..316950c --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_1.twig @@ -0,0 +1 @@ +{{user.name}} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_2.twig b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_2.twig new file mode 100644 index 0000000..b5c8d5a --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Linter/whitespace_2.twig @@ -0,0 +1,24 @@ +{% if not hash is defined %} + {% set hash = [ + {some: 42} + ] %} +{% endif %} + +{% render 'WebProfilerBundle:Exception:show' with { + 'exception': collector.exception, + 'format': 'html' +} %} + +{% if someAttribute == true %} + {# Weird indent #} + {{ path('website_search', { pattern: 'qwerty', + page: 2 + }) }} +{% endif %} + +{% embed 'AcmeBundle:Some:show.html.twig' with { some: true, none: false } %} +{% endembed %} + +{% for item in [ 0..10 ] %} + {{ item }} +{% endfor %} diff --git a/tests/Asm89/Twig/Lint/Test/Fixtures/Standards/Sniffs/DummySniff.php b/tests/Asm89/Twig/Lint/Test/Fixtures/Standards/Sniffs/DummySniff.php new file mode 100644 index 0000000..1106044 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/Fixtures/Standards/Sniffs/DummySniff.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Test; + +use Asm89\Twig\Lint\Config; +use Asm89\Twig\Lint\Config\Loader; +use Asm89\Twig\Lint\Linter; +use Asm89\Twig\Lint\Ruleset; +use Asm89\Twig\Lint\RulesetFactory; +use Asm89\Twig\Lint\Sniffs\SniffInterface; +use Asm89\Twig\Lint\StubbedEnvironment; +use Asm89\Twig\Lint\Tokenizer\Tokenizer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\FileLocator; + +class LinterTest extends TestCase +{ + private $env; + private $lint; + private $workingDirectory; + private $rulesetFactory; + private $debug; + + public function setUp() + { + $this->env = new StubbedEnvironment( + $this->getMockBuilder('Twig_LoaderInterface')->getMock(), + array( + 'stub_tags' => array('dump', 'meh', 'render', 'some_other_block', 'stylesheets', 'trans'), + 'stub_tests' => array('sometest'), + ) + ); + $this->lint = new Linter($this->env, new Tokenizer($this->env)); + $this->workingDirectory = __DIR__ . '/Fixtures'; + $this->rulesetFactory = new RulesetFactory(); + + // https://stackoverflow.com/questions/12610605 + $this->debug = in_array('--debug', $_SERVER['argv'], true); + } + + public function testNewEngine() + { + $this->assertNotNull($this->lint); + } + + /** + * @dataProvider templateFixtures + */ + public function testLinter1($filename, $expectWarnings, $expectErrors, $expectFiles, $expectLastMessageLine, $expectLastMessagePosition) + { + $file = __DIR__ . '/Fixtures/' . $filename; + + $ruleset = new Ruleset(); + $ruleset + ->addSniff(new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DisallowIncludeTagSniff()) + ->addSniff(new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureQuotesStyleSniff()) + ->addSniff(new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureWhitespaceExpressionSniff()) + ; + + $report = $this->lint->run($file, $ruleset); + if ($this->debug) { + $this->dump($report); + } + + $this->assertEquals($expectErrors, $report->getTotalErrors(), 'Number of errors'); + $this->assertEquals($expectWarnings, $report->getTotalWarnings(), 'Number of warnings'); + $this->assertEquals($expectFiles, $report->getTotalFiles(), 'Number of files'); + + $messages = $report->getMessages(); + $lastMessage = $messages[count($messages) - 1]; + + $this->assertEquals($expectLastMessageLine, $lastMessage->getLine(), 'Line number of the error'); + $this->assertEquals($expectLastMessagePosition, $lastMessage->getLinePosition(), 'Line position of the error (if any)'); + } + + /** + * @dataProvider dataDeprecatedTemplateNotationSniff + */ + public function testDeprecatedTemplateNotationSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataDisallowDumpSniff + */ + public function testDisallowDumpSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataDisallowIncludeSniff + */ + public function testDisallowIncludeSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataDisallowCommentedCodeSniff + */ + public function testDisallowCommentedCodeSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureHashAllSniff + */ + public function testEnsureHashAllSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureHashSpacingSniff + */ + public function testEnsureHashSpacingSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureTranslationArgumentsSniff + */ + public function testEnsureTranslationArgumentsSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureBlankAtEOFSniff + */ + public function testEnsureBlankAtEOFSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureQuotesStyleSniff + */ + public function testEnsureQuotesStyleSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataEnsureWhitespaceExpressionSniff + */ + public function testEnsureWhitespaceExpressionSniff($isFile, $filename, $sniff, $expects) + { + $this->checkGenericSniff($filename, $sniff, $expects); + } + + /** + * @dataProvider dataConfig1 + */ + public function testConfig1($filename, $expectLoadingError, $expectCount = 0, $expectAddErrors = true, $expectAdded = 0) + { + $loader = new Loader(new FileLocator(__DIR__ . '/Fixtures/config')); + try { + $value = $loader->load($filename); + + $this->assertFalse($expectLoadingError); + } catch (\Exception $e) { + $this->assertEquals($expectLoadingError, $e->getMessage()); + + return; + } + + $this->assertCount($expectCount, $value['ruleset']); + + $ruleset = new Ruleset(); + foreach ($value['ruleset'] as $rule) { + try { + $ruleset->addSniff(new $rule['class']); + + $this->assertFalse($expectAddErrors); + } catch (\Exception $e) { + $this->assertTrue($expectAddErrors); + } + } + + $this->assertCount($expectAdded, $ruleset->getSniffs()); + + foreach ($value['ruleset'] as $rule) { + $ruleset->removeSniff($rule['class']); + } + + $this->assertCount(0, $ruleset->getSniffs()); + } + + /** + * @dataProvider dataConfig2 + */ + public function testConfig2($configFilename, $filename, $expectSeverity) + { + $loader = new Loader(new FileLocator($this->workingDirectory . '/config')); + $config = new Config(array('workingDirectory' => $this->workingDirectory . '/config'), $loader->load($configFilename)); + $ruleset = $this->rulesetFactory->createRulesetFromConfig($config); + + $report = $this->lint->run($this->workingDirectory . '/' . $filename, $ruleset); + $messages = $report->getMessages(); + foreach ($messages as $message) { + $this->assertEquals($expectSeverity, $message->getSeverity()); + } + } + + /** + * @dataProvider dataConfig3 + */ + public function testConfig3($configArray) + { + $config = new Config($configArray); + $ruleset = $this->rulesetFactory->createRulesetFromConfig($config); + + $this->assertCount(1, $ruleset->getSniffs()); + } + + public function templateFixtures() + { + return array( + array('Linter/error_1.twig', 0, 1, 1, 1, null), + array('mixed.twig', 2, 0, 1, 1, 11), + ); + } + + public function dataDeprecatedTemplateNotationSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DeprecatedTemplateNotationSniff(); + return array( + array(true, 'Linter/include_function.twig', $sniff, array( + 'Deprecated template notation "AcmeOyoBundle:Front:index.html.twig"; use Symfony 3+ template notation with "@" instead', + )), + array(true, 'Linter/include_tag.twig', $sniff, array( + 'Deprecated template notation "AcmeOyoBundle:Front:index.html.twig"; use Symfony 3+ template notation with "@" instead', + 'Deprecated template notation "AcmeOyoBundle:Front:index@legacy.html.twig"; use Symfony 3+ template notation with "@" instead', + )), + array(true, 'Linter/extends_tag.twig', $sniff, array( + 'Deprecated template notation "AcmeOyoBundle:Front:extend.html.twig"; use Symfony 3+ template notation with "@" instead', + )), + array(true, 'Linter/embed_tag.twig', $sniff, array( + 'Deprecated template notation "AcmeOyoBundle:Front:index.html.twig"; use Symfony 3+ template notation with "@" instead', + 'Deprecated template notation "AcmeOyoBundle/Front/index@legacy.html.twig"; use Symfony 3+ template notation with "@" instead', + )), + ); + } + + public function dataDisallowDumpSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DisallowDumpSniff(); + return array( + array(true, 'Linter/dump_tag.twig', $sniff, array( + 'Call to debug tag dump() must be removed', + )), + array(true, 'Linter/dump_function.twig', $sniff, array( + 'Call to debug function dump() must be removed', + )), + ); + } + + public function dataDisallowIncludeSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DisallowIncludeTagSniff(); + return array( + array(true, 'Linter/include_function.twig', $sniff, array( + )), + array(true, 'Linter/include_tag.twig', $sniff, array( + 'Include tag is deprecated; use the include() function instead', + 'Include tag is deprecated; use the include() function instead', + 'Include tag is deprecated; use the include() function instead', + 'Include tag is deprecated; use the include() function instead', + )), + array(true, 'Linter/include_no.twig', $sniff, array( + )), + ); + } + + public function dataDisallowCommentedCodeSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DisallowCommentedCodeSniff(); + return array( + array(true, 'Linter/comment_1.twig', $sniff, array( + )), + array(true, 'Linter/comment_2.twig', $sniff, array( + 'Probable commented code found; keeping commented code is usually not advised', + )), + ); + } + + public function dataEnsureTranslationArgumentsSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureTranslationArgumentsSniff(); + return array( + array(true, 'Linter/trans_no.twig', $sniff, array( + 'Call to filter trans() requires parameter "domain"; expected 2 parameters, found 0', + 'Call to filter trans() requires parameter "lang"; expected 3 parameters, found 0', + )), + array(true, 'Linter/trans.twig', $sniff, array( + 'Call to filter trans() requires parameter "lang"; expected 3 parameters, found 2', + )), + array(true, 'Linter/transchoice.twig', $sniff, array( + 'Call to filter transchoice() requires parameter "lang"; expected 4 parameters, found 3', + )), + ); + } + + public function dataEnsureBlankAtEOFSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureBlankAtEOFSniff(); + return array( + array(true, 'Linter/eof_0.twig', $sniff, array( + 'A file must end with 1 blank line; found 0', + )), + array(true, 'Linter/eof_2.twig', $sniff, array( + 'A file must end with 1 blank line; found 3', + )), + array(true, 'Linter/eof_3.twig', $sniff, array( + 'A file must end with 1 blank line; found 0', + )), + array(true, 'Linter/empty.twig', $sniff, array( + 'A file must end with 1 blank line; found 0' + )), + ); + } + + public function dataEnsureHashAllSniff() + { + $sniff = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureHashKeyQuotesSniff(); + $sniff2 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureHashTrailingCommaSniff(); + return array( + array(true, 'Tokenizer/tokenizer_5.twig', new \Asm89\Twig\Lint\Standards\Generic\Sniffs\DisallowTabIndentSniff(), array( + 'Indentation using tabs is not allowed; use spaces instead', + 'Indentation using tabs is not allowed; use spaces instead', + 'Indentation using tabs is not allowed; use spaces instead', + )), + array(true, 'Linter/hash_1.twig', $sniff, array( + 'Hash key \'4\' requires quotes; use single quotes', + 'Hash key \'isX\' requires quotes; use single quotes', + 'Hash key \'isY\' requires quotes; use single quotes', + )), + array(true, 'Linter/hash_2.twig', $sniff, array( + 'Hash key \'is_true\' requires quotes; use single quotes', + 'Hash key \'display_errors\' requires quotes; use single quotes', + 'Hash key \'name\' requires quotes; use single quotes', + )), + array(true, 'Linter/hash_3.twig', $sniff, array()), + array(true, 'Linter/hash_4.twig', $sniff, array( + 'Hash key \'lvl1_x\' requires quotes; use single quotes', + 'Hash key \'lvl1_y\' requires quotes; use single quotes', + 'Hash key \'lvl1_y\' requires quotes; use single quotes', + 'Hash key \'lvl2_x\' requires quotes; use single quotes', + 'Hash key \'lvl3_z\' requires quotes; use single quotes', + )), + array(true, 'Linter/hash_1.twig', $sniff2, array( + 'Hash requires trailing comma after \'azerty\'' + )), + array(true, 'Linter/hash_2.twig', $sniff2, array( + 'Hash requires trailing comma after \'lastname\'' + )), + array(true, 'Linter/hash_3.twig', $sniff2, array()), + array(true, 'Linter/hash_4.twig', $sniff2, array( + 'Hash requires trailing comma after \'45.5\'', + 'Hash requires trailing comma after \'Oyoyo\'', + 'Hash requires trailing comma after \'}\'', + 'Hash requires trailing comma after \']\'', + )), + ); + } + + public function dataEnsureQuotesStyleSniff() + { + $sniff1 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureQuotesStyleSniff(); + $sniff2 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureQuotesStyleSniff(array('style' => 'TYPE_DOUBLE_QUOTES')); + return array( + array(true, 'Linter/quotes_1.twig', $sniff1, array( + 'String "@Acme/Some/base.html.twig" does not require double quotes; use single quotes instead', + 'String "some" does not require double quotes; use single quotes instead', + 'String "default" does not require double quotes; use single quotes instead', + 'String "some_value" does not require double quotes; use single quotes instead', + 'String "Value: " does not require double quotes; use single quotes instead', + 'String "." does not require double quotes; use single quotes instead', + 'String "front" does not require double quotes; use single quotes instead', + )), + array(true, 'Linter/quotes_1.twig', $sniff2, array( + 'String \'TYPE_1\' uses single quotes; use double quotes instead', + )), + ); + } + + public function dataEnsureWhitespaceExpressionSniff() + { + $sniff1 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureWhitespaceExpressionSniff(); + $sniff2 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureWhitespaceExpressionSniff(array('count' => 0)); + return array( + array(true, 'Linter/whitespace_1.twig', $sniff1, array( + 'Expecting 1 whitespace AFTER start of expression eg. "{{" or "{%"; found 0', + 'Expecting 1 whitespace BEFORE end of expression eg. "}}" or "%}"; found 0', + )), + array(true, 'Linter/whitespace_1.twig', $sniff2, array( + )), + ); + } + + public function dataEnsureHashSpacingSniff() + { + $sniff1 = new \Asm89\Twig\Lint\Standards\Generic\Sniffs\EnsureHashSpacingSniff(); + return array( + array(true, 'Linter/whitespace_2.twig', $sniff1, array( + 'Expecting 0 whitespace AFTER "{"; found 3', + 'Expecting 0 whitespace AFTER "{"; found 1', + 'Expecting 0 whitespace BEFORE "}"; found 1', + 'Expecting 0 whitespace AFTER "["; found 1', + 'Expecting 0 whitespace BEFORE "]"; found 1', + )), + ); + } + + public function dataConfig1() + { + return array( + array('twigcs_0.yml', 'File "twigcs_0.yml" not found.'), + array('twigcs_1.yml', false, 2, false, 2), + array('twigcs_2.yml', false, 1, true, 0), + array('twigcs_3.yml', 'Missing "class" key'), + array('twigcs_4.yml', 'Missing "ruleset" key'), + ); + } + + public function dataConfig2() + { + return array( + array('twigcs_1.yml', 'Linter/dump_function.twig', 10), + ); + } + + public function dataConfig3() + { + return array( + array(array( + 'ruleset' => array( + array('class' => '\Acme\Standards\TwigCS\Sniffs\DummySniff'), + ), + 'standardPaths' => array( + '\Acme\Standards\TwigCS' => array(__DIR__ . '/Fixtures/Standards/'), + ), + )), + ); + } + + protected function checkGenericSniff($filename, $sniff, $expects) + { + $file = __DIR__ . '/Fixtures/' . $filename; + + $ruleset = new Ruleset(); + $ruleset + ->addSniff($sniff) + ; + + $report = $this->lint->run($file, $ruleset); + if ($this->debug) { + $this->dump($report); + } + + $this->assertEquals(count($expects), $report->getTotalWarnings() + $report->getTotalErrors()); + if ($expects) { + $messageStrings = array_map(function ($message) { + return $message->getMessage(); + }, $report->getMessages()); + + foreach ($expects as $expect) { + $this->assertContains($expect, $messageStrings); + } + } + } + + protected function dump($any) + { + if (function_exists('dump')) { + return dump($any); + } + + return var_dump($any); + } +} diff --git a/tests/Asm89/Twig/Lint/Test/StubbedEnvironmentTest.php b/tests/Asm89/Twig/Lint/Test/StubbedEnvironmentTest.php index 7412cfd..be043d9 100644 --- a/tests/Asm89/Twig/Lint/Test/StubbedEnvironmentTest.php +++ b/tests/Asm89/Twig/Lint/Test/StubbedEnvironmentTest.php @@ -12,32 +12,39 @@ namespace Asm89\Twig\Lint\Test; use Asm89\Twig\Lint\StubbedEnvironment; -use Twig_Error; +use PHPUnit\Framework\TestCase; +use \Twig_Error; /** * @author Alexander */ -class StubbedEnvironmentTest extends \PHPUnit_Framework_TestCase +class StubbedEnvironmentTest extends TestCase { private $env; public function setup() { - $this->env = new StubbedEnvironment(); + $this->env = new StubbedEnvironment( + $this->getMockBuilder('Twig_LoaderInterface')->getMock(), + array( + 'stub_tags' => array('meh', 'render', 'some_other_block', 'stylesheets', 'trans'), + 'stub_tests' => array('created by', 'sometest', 'some_undefined_test', 'some_undefined_test_with_args'), + ) + ); } public function testGetFilterAlwaysReturnsAFilter() { $filter = $this->env->getFilter('foo'); - $this->assertInstanceOf('Twig_Filter', $filter); + $this->assertInstanceOf('Twig_SimpleFilter', $filter); } public function testGetFunctionAlwaysReturnsAFunction() { $function = $this->env->getFunction('foo'); - $this->assertInstanceOf('Twig_Function', $function); + $this->assertInstanceOf('Twig_SimpleFunction', $function); } /** @@ -48,12 +55,12 @@ public function testParseTemplatesWithUndefinedElements($filename) $file = __DIR__ . '/Fixtures/' . $filename; $template = file_get_contents($file); try { - $this->env->parse($this->env->tokenize($template, $file)); + $this->env->parse($this->env->tokenize(new \Twig_Source($template, $file))); } catch (Twig_Error $exception) { - $this->assertTrue(false, "Was unable to parse the template."); + $this->assertTrue(false, sprintf('Was unable to parse the template: "%s"', $exception->getMessage())); } - $this->assertTrue(true, "Was able to parse the template."); + $this->assertTrue(true, 'Was able to parse the template.'); } public function templateFixtures() diff --git a/tests/Asm89/Twig/Lint/Test/TokenizerTest.php b/tests/Asm89/Twig/Lint/Test/TokenizerTest.php new file mode 100644 index 0000000..5009183 --- /dev/null +++ b/tests/Asm89/Twig/Lint/Test/TokenizerTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Asm89\Twig\Lint\Test; + +use Asm89\Twig\Lint\Tokenizer\Tokenizer; +use Asm89\Twig\Lint\Preprocessor\Token; +use PHPUnit\Framework\TestCase; +use \Twig_Environment; + +class TokenizerTest extends TestCase +{ + /** + * @dataProvider templateFixtures + */ + public function testTokenizer($filename, $expectedTokenCount) + { + $file = __DIR__ . '/Fixtures/' . $filename; + $template = file_get_contents($file); + + $tokenizer = new Tokenizer(new Twig_Environment($this->getMockBuilder('Twig_LoaderInterface')->getMock())); + + $stream = $tokenizer->tokenize(new \Twig_Source($template, $filename, $file)); + + $this->assertCount($expectedTokenCount, $stream); + } + + public function templateFixtures() + { + return array( + array('Tokenizer/tokenizer_1.twig', 52), + array('Tokenizer/tokenizer_2.twig', 10), + array('Tokenizer/tokenizer_3.twig', 15), + array('Tokenizer/tokenizer_4.twig', 199), + array('Tokenizer/tokenizer_5.twig', 46), + array('mixed.twig', 385), + ); + } +}