diff --git a/TwigCS/Command/TwigCSCommand.php b/TwigCS/Command/TwigCSCommand.php new file mode 100644 index 0000000..c3bcae2 --- /dev/null +++ b/TwigCS/Command/TwigCSCommand.php @@ -0,0 +1,105 @@ +setName('lint') + ->setDescription('Lints a template and outputs encountered errors') + ->setDefinition([ + new InputOption( + 'exclude', + 'e', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Excludes, based on regex, paths of files and folders from parsing', + ['vendor/'] + ), + new InputOption( + 'level', + 'l', + InputOption::VALUE_OPTIONAL, + 'Allowed values are: warning, error', + 'warning' + ), + new InputOption( + 'working-dir', + 'd', + InputOption::VALUE_OPTIONAL, + 'Run as if this was started in instead of the current working directory', + getcwd() + ), + ]) + ->addArgument( + 'paths', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'Paths of files and folders to parse', + null + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + * + * @throws \Exception + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $paths = $input->getArgument('paths'); + $exclude = $input->getOption('exclude'); + $level = $input->getOption('level'); + $currentDir = $input->getOption('working-dir'); + + $config = new Config([ + 'paths' => $paths, + 'exclude' => $exclude, + 'workingDirectory' => $currentDir, + ]); + + $twig = new StubbedEnvironment(new ArrayLoader(), ['stub_tags' => $config->get('stub')]); + $linter = new Linter($twig, new Tokenizer($twig)); + $factory = new RulesetFactory(); + $reporter = new TextFormatter($input, $output); + $exitCode = 0; + + // Get the rules to apply. + $ruleset = $factory->createStandardRuleset(); + + // Execute the linter. + $report = $linter->run($config->findFiles(), $ruleset); + + // Format the output. + $reporter->display($report, $level); + + // Return a meaningful error code. + if ($report->getTotalErrors()) { + $exitCode = 1; + } + + return $exitCode; + } +} diff --git a/TwigCS/Config/Config.php b/TwigCS/Config/Config.php new file mode 100644 index 0000000..1aa5095 --- /dev/null +++ b/TwigCS/Config/Config.php @@ -0,0 +1,86 @@ + [], + 'pattern' => '*.twig', + 'paths' => [], + 'stub' => [], + 'workingDirectory' => '', + ]; + + /** + * Current configuration. + * + * @var array + */ + protected $config; + + public function __construct() + { + $args = func_get_args(); + + $this->config = $this::$defaultConfig; + foreach ($args as $arg) { + $this->config = array_merge($this->config, $arg); + } + } + + /** + * Find all files to process, based on a file or directory and exclude patterns. + * + * @return array + */ + public function findFiles() + { + $paths = $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 ($paths 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 mixed + */ + 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/TwigCS/Environment/StubbedEnvironment.php b/TwigCS/Environment/StubbedEnvironment.php new file mode 100644 index 0000000..054901a --- /dev/null +++ b/TwigCS/Environment/StubbedEnvironment.php @@ -0,0 +1,115 @@ +stubCallable = function () { + /* This will be used as stub filter, function or test */ + }; + + $this->stubFilters = []; + $this->stubFunctions = []; + + if (isset($options['stub_tags'])) { + foreach ($options['stub_tags'] as $tag) { + $this->addTokenParser(new TokenParser($tag)); + } + } + + $this->stubTests = []; + if (isset($options['stub_tests'])) { + foreach ($options['stub_tests'] as $test) { + $this->stubTests[$test] = new TwigTest('stub', $this->stubCallable); + } + } + + $this->addExtension(new SniffsExtension()); + } + + /** + * @param string $name + * + * @return TwigFilter + */ + public function getFilter($name) + { + if (!isset($this->stubFilters[$name])) { + $this->stubFilters[$name] = new TwigFilter('stub', $this->stubCallable); + } + + return $this->stubFilters[$name]; + } + + /** + * @param string $name + * + * @return TwigFunction + */ + public function getFunction($name) + { + if (!isset($this->stubFunctions[$name])) { + $this->stubFunctions[$name] = new TwigFunction('stub', $this->stubCallable); + } + + return $this->stubFunctions[$name]; + } + + /** + * @param string $name + * + * @return false|TwigTest + */ + public function getTest($name) + { + $test = parent::getTest($name); + if ($test) { + return $test; + } + + if (isset($this->stubTests[$name])) { + return $this->stubTests[$name]; + } + + return false; + } +} diff --git a/TwigCS/Extension/SniffsExtension.php b/TwigCS/Extension/SniffsExtension.php new file mode 100644 index 0000000..1947436 --- /dev/null +++ b/TwigCS/Extension/SniffsExtension.php @@ -0,0 +1,64 @@ +nodeVisitor = new SniffsNodeVisitor(); + } + + /** + * @return NodeVisitorInterface[] + */ + public function getNodeVisitors() + { + return [$this->nodeVisitor]; + } + + /** + * Register a sniff in the node visitor. + * + * @param PostParserSniffInterface $sniff + * + * @return self + */ + 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/TwigCS/Extension/SniffsNodeVisitor.php b/TwigCS/Extension/SniffsNodeVisitor.php new file mode 100644 index 0000000..f9c6bab --- /dev/null +++ b/TwigCS/Extension/SniffsNodeVisitor.php @@ -0,0 +1,139 @@ +sniffs = []; + $this->enabled = true; + } + + /** + * @return int + */ + 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 $toBeRemovedSniff + * + * @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; + + return $this; + } + + /** + * @param Node $node + * @param Environment $env + * + * @return Node + */ + protected function doEnterNode(Node $node, Environment $env) + { + if (!$this->enabled) { + return $node; + } + + foreach ($this->getSniffs() as $sniff) { + $sniff->process($node, $env); + } + + return $node; + } + + /** + * @param Node $node + * @param Environment $env + * + * @return Node + */ + protected function doLeaveNode(Node $node, Environment $env) + { + return $node; + } +} diff --git a/TwigCS/Linter.php b/TwigCS/Linter.php new file mode 100644 index 0000000..b848953 --- /dev/null +++ b/TwigCS/Linter.php @@ -0,0 +1,165 @@ +env = $env; + $this->sniffsExtension = $this->env->getExtension('TwigCS\Extension\SniffsExtension'); + $this->tokenizer = $tokenizer; + } + + /** + * Run the linter on the given $files against the given $ruleset. + * + * @param array|string $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 = [$files]; + } + + if (empty($files)) { + throw new \Exception('No files to process, provide at least one file to be linted'); + } + + // setUp + $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->sniffsExtension->addSniff($sniff); + } + + $sniff->enable($report); + } + + // Process + foreach ($files as $file) { + $this->processTemplate($file, $ruleset, $report); + + // Add this file to the report. + $report->addFile($file); + } + + // tearDown + restore_error_handler(); + foreach ($ruleset->getSniffs() as $sniff) { + if ($sniff instanceof PostParserSniffInterface) { + $this->sniffsExtension->removeSniff($sniff); + } + + $sniff->disable(); + } + + 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 bool + */ + public function processTemplate($file, $ruleset, $report) + { + $twigSource = new Source(file_get_contents($file), $file, $file); + + // Tokenize + Parse. + try { + $this->env->parse($this->env->tokenize($twigSource)); + } catch (Error $e) { + $sniffViolation = new SniffViolation( + SniffInterface::MESSAGE_TYPE_ERROR, + $e->getRawMessage(), + $e->getTemplateLine(), + $e->getSourceContext()->getName() + ); + + $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 + ); + + $report->addMessage($sniffViolation); + + return false; + } + + /** @var PreParserSniffInterface[] $sniffs */ + $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/TwigCS/Report/Report.php b/TwigCS/Report/Report.php new file mode 100644 index 0000000..f507067 --- /dev/null +++ b/TwigCS/Report/Report.php @@ -0,0 +1,155 @@ +messages = []; + $this->files = []; + $this->totalNotices = 0; + $this->totalWarnings = 0; + $this->totalErrors = 0; + } + + /** + * @param SniffViolation $sniffViolation + * + * @return $this + */ + 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; + } + + /** + * @param array $filters + * + * @return SniffViolation[] + */ + public function getMessages($filters = []) + { + if (empty($filters)) { + // Return all messages, without filtering. + return $this->messages; + } + + return array_filter($this->messages, function (SniffViolation $message) use ($filters) { + $fileFilter = $levelFilter = 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']); + } + + return $fileFilter && $levelFilter; + }); + } + + /** + * @param string $file + */ + public function addFile($file) + { + $this->files[] = $file; + } + + /** + * @return string[] + */ + public function getFiles() + { + return $this->files; + } + + /** + * @return int + */ + public function getTotalFiles() + { + return count($this->files); + } + + /** + * @return int + */ + public function getTotalMessages() + { + return count($this->messages); + } + + /** + * @return int + */ + public function getTotalNotices() + { + return $this->totalNotices; + } + + /** + * @return int + */ + public function getTotalWarnings() + { + return $this->totalWarnings; + } + + /** + * @return int + */ + public function getTotalErrors() + { + return $this->totalErrors; + } +} diff --git a/TwigCS/Report/SniffViolation.php b/TwigCS/Report/SniffViolation.php new file mode 100644 index 0000000..4433d90 --- /dev/null +++ b/TwigCS/Report/SniffViolation.php @@ -0,0 +1,198 @@ +level = $level; + $this->message = $message; + $this->line = $line; + $this->filename = $filename; + + $this->sniff = null; + $this->linePosition = null; + } + + /** + * 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. + * + * @param int $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 sniff that was not met. + * + * @param SniffInterface $sniff + * + * @return self + */ + 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/TwigCS/Report/TextFormatter.php b/TwigCS/Report/TextFormatter.php new file mode 100644 index 0000000..3f6f244 --- /dev/null +++ b/TwigCS/Report/TextFormatter.php @@ -0,0 +1,134 @@ +>'; + const ERROR_LINE_FORMAT = '%-5s| %s'; + const ERROR_CONTEXT_LIMIT = 2; + const ERROR_LINE_WIDTH = 120; + + /** + * Input-output helper object. + * + * @var SymfonyStyle + */ + protected $io; + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + public function __construct($input, $output) + { + $this->io = new SymfonyStyle($input, $output); + } + + /** + * @param Report $report + * @param string|null $level + */ + public function display(Report $report, $level = null) + { + foreach ($report->getFiles() as $file) { + $fileMessages = $report->getMessages([ + 'file' => $file, + 'level' => $level, + ]); + + $this->io->text((count($fileMessages) > 0 ? 'KO' : 'OK').' '.$file); + + $rows = []; + foreach ($fileMessages as $message) { + $lines = $this->getContext(file_get_contents($file), $message->getLine(), $this::ERROR_CONTEXT_LIMIT); + + $formattedText = []; + 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[] = [ + new TableCell(''.$message->getLevelAsString().'', ['rowspan' => 2]), + implode("\n", $formattedText), + ]; + $rows[] = new TableSeparator(); + } + + $this->io->table([], $rows); + } + + $summaryString = sprintf( + 'Files linted: %d, notices: %d, warnings: %d, errors: %d', + $report->getTotalFiles(), + $report->getTotalNotices(), + $report->getTotalWarnings(), + $report->getTotalErrors() + ); + + if (0 === $report->getTotalWarnings() && 0 === $report->getTotalErrors()) { + $this->io->block($summaryString, 'SUCCESS', 'fg=black;bg=green', ' ', true); + } elseif (0 < $report->getTotalWarnings() && 0 === $report->getTotalErrors()) { + $this->io->block($summaryString, 'WARNING', 'fg=black;bg=yellow', ' ', true); + } else { + $this->io->block($summaryString, 'ERROR', 'fg=black;bg=red', ' ', true); + } + } + + /** + * @param string $template + * @param int $line + * @param int $context + * + * @return array + */ + protected function getContext($template, $line, $context) + { + $lines = explode("\n", $template); + + $position = max(0, $line - $context); + $max = min(count($lines), $line - 1 + $context); + + $result = []; + $indentCount = null; + while ($position < $max) { + if (preg_match('/^([\s\t]+)/', $lines[$position], $match)) { + if (null === $indentCount) { + $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; + } +} diff --git a/TwigCS/Ruleset/Ruleset.php b/TwigCS/Ruleset/Ruleset.php new file mode 100644 index 0000000..719bd1f --- /dev/null +++ b/TwigCS/Ruleset/Ruleset.php @@ -0,0 +1,111 @@ +sniffs = []; + } + + /** + * @param array|null $types + * + * @return SniffInterface[] + */ + public function getSniffs($types = null) + { + if (null === $types) { + $types = [SniffInterface::TYPE_PRE_PARSER, SniffInterface::TYPE_POST_PARSER]; + } + + if (null !== $types && !is_array($types)) { + $types = [$types]; + } + + return array_filter($this->sniffs, function (SniffInterface $sniff) use ($types) { + return in_array($sniff->getType(), $types); + }); + } + + /** + * @param PreParserSniffInterface $sniff + * + * @return $this + */ + public function addPreParserSniff(PreParserSniffInterface $sniff) + { + $this->sniffs[get_class($sniff)] = $sniff; + + return $this; + } + + /** + * @param PostParserSniffInterface $sniff + * + * @return $this + */ + public function addPostParserSniff(PostParserSniffInterface $sniff) + { + $this->sniffs[get_class($sniff)] = $sniff; + + return $this; + } + + /** + * @param SniffInterface $sniff + * + * @return $this + */ + public function addSniff(SniffInterface $sniff) + { + if (SniffInterface::TYPE_PRE_PARSER === $sniff->getType()) { + // Store this type of sniff locally. + /** @var PreParserSniffInterface $sniff */ + $this->addPreParserSniff($sniff); + + return $this; + } + + if (SniffInterface::TYPE_POST_PARSER === $sniff->getType()) { + // Store this type of sniff locally. + /** @var PostParserSniffInterface $sniff */ + $this->addPostParserSniff($sniff); + + return $this; + } + + throw new \Exception(sprintf( + 'Unknown type of sniff "%s", expected one of: "%s"', + $sniff->getType(), + implode(', ', [SniffInterface::TYPE_PRE_PARSER, SniffInterface::TYPE_POST_PARSER]) + )); + } + + /** + * @param string $sniffClass + * + * @return $this + */ + public function removeSniff($sniffClass) + { + if (isset($this->sniffs[$sniffClass])) { + unset($this->sniffs[$sniffClass]); + } + + return $this; + } +} diff --git a/TwigCS/Ruleset/RulesetFactory.php b/TwigCS/Ruleset/RulesetFactory.php new file mode 100644 index 0000000..8d62322 --- /dev/null +++ b/TwigCS/Ruleset/RulesetFactory.php @@ -0,0 +1,31 @@ +in(__DIR__.'/../Sniff/Standard')->files(); + + /** @var \SplFileInfo $file */ + foreach ($finder as $file) { + $class = 'TwigCS\Sniff\Standard\\'.explode('.', $file->getFilename())[0]; + $ruleset->addSniff(new $class()); + } + + return $ruleset; + } +} diff --git a/TwigCS/Sniff/AbstractPostParserSniff.php b/TwigCS/Sniff/AbstractPostParserSniff.php new file mode 100644 index 0000000..e040317 --- /dev/null +++ b/TwigCS/Sniff/AbstractPostParserSniff.php @@ -0,0 +1,168 @@ +getTemplateLine($node), + $this->getTemplateName($node) + ); + + $this->getReport()->addMessage($sniffViolation); + + return $this; + } + + /** + * @param Node $node + * + * @return string + */ + public function getTemplateLine(Node $node) + { + if (method_exists($node, 'getTemplateLine')) { + return $node->getTemplateLine(); + } + + if (method_exists($node, 'getLine')) { + return $node->getLine(); + } + + return ''; + } + + /** + * @param Node $node + * + * @return string + */ + public function getTemplateName(Node $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 ''; + } + + /** + * @param Node $node + * @param string $type + * @param string|null $name + * + * @return bool + */ + public function isNodeMatching(Node $node, $type, $name = null) + { + $typeToClass = [ + 'filter' => function (Node $node, $type, $name) { + return $node instanceof FilterExpression + && $name === $node->getNode($type)->getAttribute('value'); + }, + 'function' => function (Node $node, $type, $name) { + return $node instanceof FunctionExpression + && $name === $node->getAttribute('name'); + }, + 'include' => function (Node $node, $type, $name) { + return $node instanceof IncludeNode; + }, + 'tag' => function (Node $node, $type, $name) { + return $node->getNodeTag() === $name; + }, + ]; + + if (!isset($typeToClass[$type])) { + return false; + } + + return $typeToClass[$type]($node, $type, $name); + } + + /** + * @param mixed $value + * + * @return string + */ + public function stringifyValue($value) + { + if (null === $value) { + return 'null'; + } + + if (is_bool($value)) { + return ($value) ? 'true' : 'false'; + } + + return (string) $value; + } + + /** + * @param Node $node + * + * @return mixed|string + */ + public function stringifyNode(Node $node) + { + $stringValue = ''; + + if ($node instanceof GetAttrExpression) { + return $node->getNode('node')->getAttribute('name').'.'.$this->stringifyNode($node->getNode('attribute')); + } + if ($node instanceof ConcatBinary) { + return $this->stringifyNode($node->getNode('left')).' ~ '.$this->stringifyNode($node->getNode('right')); + } + if ($node instanceof ConstantExpression) { + return $node->getAttribute('value'); + } + + return $stringValue; + } +} diff --git a/TwigCS/Sniff/AbstractPreParserSniff.php b/TwigCS/Sniff/AbstractPreParserSniff.php new file mode 100644 index 0000000..bfe8c40 --- /dev/null +++ b/TwigCS/Sniff/AbstractPreParserSniff.php @@ -0,0 +1,73 @@ +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 + * + * @return self + */ + public function addMessage($messageType, $message, Token $token) + { + $sniffViolation = new SniffViolation($messageType, $message, $token->getLine(), $token->getFilename()); + $sniffViolation->setLinePosition($token->getPosition()); + + $this->getReport()->addMessage($sniffViolation); + + return $this; + } + + /** + * @param Token $token + * + * @return string + */ + public function stringifyValue($token) + { + if ($token->getType() === Token::STRING_TYPE) { + return $token->getValue(); + } + + return '\''.$token->getValue().'\''; + } +} diff --git a/TwigCS/Sniff/AbstractSniff.php b/TwigCS/Sniff/AbstractSniff.php new file mode 100644 index 0000000..242672a --- /dev/null +++ b/TwigCS/Sniff/AbstractSniff.php @@ -0,0 +1,68 @@ +messages = []; + $this->report = null; + } + + /** + * @param Report $report + * + * @return self + */ + public function enable(Report $report) + { + $this->report = $report; + + return $this; + } + + /** + * @return self + */ + public function disable() + { + $this->report = null; + + return $this; + } + + /** + * @return Report + */ + public function getReport() + { + if (null === $this->report) { + throw new \Exception('Sniff is disabled!'); + } + + return $this->report; + } + + /** + * @return string + */ + abstract public function getType(); +} diff --git a/TwigCS/Sniff/PostParserSniffInterface.php b/TwigCS/Sniff/PostParserSniffInterface.php new file mode 100644 index 0000000..5997e95 --- /dev/null +++ b/TwigCS/Sniff/PostParserSniffInterface.php @@ -0,0 +1,18 @@ +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/TwigCS/Sniff/Standard/EnsureBlankAtEOFSniff.php b/TwigCS/Sniff/Standard/EnsureBlankAtEOFSniff.php new file mode 100644 index 0000000..8106059 --- /dev/null +++ b/TwigCS/Sniff/Standard/EnsureBlankAtEOFSniff.php @@ -0,0 +1,42 @@ +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_ERROR, + sprintf('A file must end with 1 blank line; found %d', $i), + $token + ); + } + } + + return $token; + } +} diff --git a/TwigCS/Tests/AbstractSniffTest.php b/TwigCS/Tests/AbstractSniffTest.php new file mode 100644 index 0000000..7585dc3 --- /dev/null +++ b/TwigCS/Tests/AbstractSniffTest.php @@ -0,0 +1,72 @@ +getMockBuilder(LoaderInterface::class)->getMock(); + $this->env = new StubbedEnvironment( + $twigLoaderInterface, + [ + 'stub_tags' => ['dump', 'render', 'some_other_block', 'stylesheets', 'trans'], + 'stub_tests' => ['some_test'], + ] + ); + $this->lint = new Linter($this->env, new Tokenizer($this->env)); + } + + /** + * @param string $filename + * @param SniffInterface $sniff + * @param array $expects + * + * @throws \Exception + */ + protected function checkGenericSniff($filename, SniffInterface $sniff, array $expects) + { + $file = __DIR__.'/Fixtures/'.$filename; + + $ruleset = new Ruleset(); + $ruleset->addSniff($sniff); + + $report = $this->lint->run($file, $ruleset); + + $this->assertEquals(count($expects), $report->getTotalWarnings() + $report->getTotalErrors()); + if ($expects) { + $messagePositions = array_map(function (SniffViolation $message) { + return [ + $message->getLine(), + $message->getLinePosition(), + ]; + }, $report->getMessages()); + + $this->assertEquals($expects, $messagePositions); + } + } +} diff --git a/TwigCS/Tests/Fixtures/disallowCommentedCode.twig b/TwigCS/Tests/Fixtures/disallowCommentedCode.twig new file mode 100644 index 0000000..cc03dae --- /dev/null +++ b/TwigCS/Tests/Fixtures/disallowCommentedCode.twig @@ -0,0 +1,7 @@ +
+ {# {% set var = 'This is a comment' %} #} +
+ +
+ {# 'This is a comment' #} +
diff --git a/TwigCS/Tests/Fixtures/ensureBlankAtEOF.twig b/TwigCS/Tests/Fixtures/ensureBlankAtEOF.twig new file mode 100644 index 0000000..99dfd42 --- /dev/null +++ b/TwigCS/Tests/Fixtures/ensureBlankAtEOF.twig @@ -0,0 +1,3 @@ +
+
+ diff --git a/TwigCS/Tests/Sniff/DisallowCommentedCodeTest.php b/TwigCS/Tests/Sniff/DisallowCommentedCodeTest.php new file mode 100644 index 0000000..40e5b53 --- /dev/null +++ b/TwigCS/Tests/Sniff/DisallowCommentedCodeTest.php @@ -0,0 +1,19 @@ +checkGenericSniff('disallowCommentedCode.twig', new DisallowCommentedCodeSniff(), [ + [2, 5], + ]); + } +} diff --git a/TwigCS/Tests/Sniff/EnsureBlankAtEOFTest.php b/TwigCS/Tests/Sniff/EnsureBlankAtEOFTest.php new file mode 100644 index 0000000..7b8b7c4 --- /dev/null +++ b/TwigCS/Tests/Sniff/EnsureBlankAtEOFTest.php @@ -0,0 +1,19 @@ +checkGenericSniff('ensureBlankAtEOF.twig', new EnsureBlankAtEOFSniff(), [ + [4, 1], + ]); + } +} diff --git a/TwigCS/Token/Token.php b/TwigCS/Token/Token.php new file mode 100644 index 0000000..1c471af --- /dev/null +++ b/TwigCS/Token/Token.php @@ -0,0 +1,114 @@ +type = $type; + $this->line = $line; + $this->position = $position; + $this->filename = $filename; + $this->value = $value; + } + + /** + * @return int + */ + public function getType() + { + return $this->type; + } + + /** + * @return int + */ + public function getLine() + { + return $this->line; + } + + /** + * @return int + */ + public function getPosition() + { + return $this->position; + } + + /** + * @return string + */ + public function getFilename() + { + return $this->filename; + } + + /** + * @return string|null + */ + public function getValue() + { + return $this->value; + } +} diff --git a/TwigCS/Token/TokenParser.php b/TwigCS/Token/TokenParser.php new file mode 100644 index 0000000..543633d --- /dev/null +++ b/TwigCS/Token/TokenParser.php @@ -0,0 +1,98 @@ +name = $name; + } + + /** + * @param Token $token + * + * @return bool + */ + public function decideEnd(Token $token) + { + return $token->test('end'.$this->name); + } + + /** + * @param Token $token + * + * @return Node + */ + public function parse(Token $token) + { + $stream = $this->parser->getStream(); + + while ($stream->getCurrent()->getType() !== Token::BLOCK_END_TYPE) { + $stream->next(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + if ($this->hasBody($stream)) { + $this->parser->subparse([$this, 'decideEnd'], true); + $stream->expect(Token::BLOCK_END_TYPE); + } + + $attributes = []; + if ($token->getValue()) { + $attributes['name'] = $token->getValue(); + } + + return new Node([], $attributes, $token->getLine(), $token->getValue() ?: null); + } + + /** + * @return string + */ + public function getTag() + { + return $this->name; + } + + /** + * @param TokenStream $stream + * + * @return bool + */ + private function hasBody(TokenStream $stream) + { + $look = 0; + while ($token = $stream->look($look)) { + if ($token->getType() === Token::EOF_TYPE) { + return false; + } + + if ($token->getType() === Token::NAME_TYPE + && $token->getValue() === 'end'.$this->name + ) { + return true; + } + + $look++; + } + + return false; + } +} diff --git a/TwigCS/Token/Tokenizer.php b/TwigCS/Token/Tokenizer.php new file mode 100644 index 0000000..28b1076 --- /dev/null +++ b/TwigCS/Token/Tokenizer.php @@ -0,0 +1,451 @@ +options = array_merge([ + 'tag_comment' => ['{#', '#}'], + 'tag_block' => ['{%', '%}'], + 'tag_variable' => ['{{', '}}'], + 'whitespace_trim' => '-', + 'whitespace_line_trim' => '~', + 'whitespace_line_chars' => ' \t\0\x0B', + 'interpolation' => ['#{', '}'], + ], $options); + + $tokenizerHelper = new TokenizerHelper($env, $this->options); + $this->regexes = [ + 'lex_var' => $tokenizerHelper->getVarRegex(), + 'lex_block' => $tokenizerHelper->getBlockRegex(), + 'lex_raw_data' => $tokenizerHelper->getRawDataRegex(), + 'operator' => $tokenizerHelper->getOperatorRegex(), + 'lex_comment' => $tokenizerHelper->getCommentRegex(), + 'lex_block_raw' => $tokenizerHelper->getBlockRawRegex(), + 'lex_block_line' => $tokenizerHelper->getBlockLineRegex(), + 'lex_tokens_start' => $tokenizerHelper->getTokensStartRegex(), + 'interpolation_start' => $tokenizerHelper->getInterpolationStartRegex(), + 'interpolation_end' => $tokenizerHelper->getInterpolationEndRegex(), + ]; + } + + /** + * @param Source $source + * + * @return Token[] + */ + public function tokenize(Source $source) + { + $this->resetState($source); + $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_STRING: +// $this->lexString(); +// break; +// case self::STATE_INTERPOLATION: +// $this->lexInterpolation(); +// break; + case self::STATE_DATA: + if ($this->cursor === $nextToken['position']) { + $this->lexStart(); + } else { + $this->lexData(); + } + break; + default: + throw new \Exception('Unhandled state in tokenize', 1); + } + } + + if (self::STATE_DATA !== $this->getState()) { + throw new \Exception('Error Processing Request', 1); + } + + $this->pushToken(Token::EOF_TYPE); + + return $this->tokens; + } + + /** + * @param Source $source + */ + protected function resetState(Source $source) + { + $this->cursor = 0; + $this->line = 1; + $this->currentPosition = 0; + $this->tokens = []; + $this->state = []; + + $this->code = str_replace(["\r\n", "\r"], "\n", $source->getCode()); + $this->end = \strlen($this->code); + $this->filename = $source->getName(); + } + + /** + * @return int + */ + protected function getState() + { + return !empty($this->state) ? $this->state[count($this->state) - 1] : self::STATE_DATA; + } + + /** + * @param int $state + */ + protected function pushState($state) + { + $this->state[] = $state; + } + + /** + * @throws \Exception + */ + protected function popState() + { + if (0 === count($this->state)) { + throw new \Exception('Cannot pop state without a previous state'); + } + array_pop($this->state); + } + + /** + * @param string $code + */ + protected function preflightSource($code) + { + $tokenPositions = []; + preg_match_all($this->regexes['lex_tokens_start'], $code, $tokenPositions, PREG_OFFSET_CAPTURE); + + $tokenPositionsReworked = []; + foreach ($tokenPositions[0] as $index => $tokenFullMatch) { + $tokenPositionsReworked[$index] = [ + 'fullMatch' => $tokenFullMatch[0], + 'position' => $tokenFullMatch[1], + 'match' => $tokenPositions[1][$index][0], + ]; + } + + $this->tokenPositions = $tokenPositionsReworked; + } + + /** + * @return array|null + */ + protected function getTokenPosition() + { + if (empty($this->tokenPositions) || !isset($this->tokenPositions[$this->currentPosition])) { + return null; + } + + return $this->tokenPositions[$this->currentPosition]; + } + + /** + * @param int $value + */ + protected function moveCurrentPosition($value = 1) + { + $this->currentPosition += $value; + } + + /** + * @param string $value + */ + protected function moveCursor($value) + { + $this->cursor += strlen($value); + $this->line += substr_count($value, "\n"); + } + + /** + * @param int $type + * @param string|null $value + */ + protected function pushToken($type, $value = null) + { + $tokenPositionInLine = $this->cursor - strrpos(substr($this->code, 0, $this->cursor), PHP_EOL); + $this->tokens[] = new Token($type, $this->line, $tokenPositionInLine, $this->filename, $value); + } + + /** + * @param int $endType + * @param string $endRegex + * + * @throws \Exception + */ + protected function lex($endType, $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->line)); + } + 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(); + } + } + } + } + + /** + * @throws \Exception + */ + 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[] = [$this->code[$this->cursor], $this->line]; + } elseif (false !== strpos(')]}', $this->code[$this->cursor])) { + // closing bracket + if (empty($this->brackets)) { + throw new \Exception(sprintf('Unexpected "%s".', $this->code[$this->cursor])); + } + $expect = array_pop($this->brackets)[0]; + if (strtr($expect, '([{', ')]}') !== $this->code[$this->cursor]) { + 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])); + } + } + + /** + * @throws \Exception + */ + protected function lexBlock() + { + $this->lex(Token::BLOCK_END_TYPE, $this->regexes['lex_block']); + } + + /** + * @throws \Exception + */ + protected function lexVariable() + { + $this->lex(Token::VAR_END_TYPE, $this->regexes['lex_variable']); + } + + /** + * @throws \Exception + */ + protected function lexComment() + { + $this->lex(Token::COMMENT_END_TYPE, $this->regexes['lex_comment']); + } + + /** + * @param int $limit + */ + 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['lex_tokens_start'], $value, $matches); + if ($nbTokenStart) { + $this->moveCurrentPosition($nbTokenStart); + } + $this->pushToken(Token::TEXT_TYPE, $value); + $this->moveCursor($value); + } + } + + /** + * @throws \Exception + */ + 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; + } else { + throw new \Exception(sprintf('Unhandled tag "%s" in lexStart', $tokenStart['match']), 1); + } + + $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]); + } +} diff --git a/TwigCS/Token/TokenizerHelper.php b/TwigCS/Token/TokenizerHelper.php new file mode 100644 index 0000000..a9c4793 --- /dev/null +++ b/TwigCS/Token/TokenizerHelper.php @@ -0,0 +1,194 @@ +env = $env; + $this->options = $options; + } + + /** + * @return string + */ + public function getVarRegex() + { + return '{ + \s* + (?:'. + preg_quote($this->options['whitespace_trim'].$this->options['tag_variable'][1], '#').'\s*'. + '|'. + preg_quote($this->options['whitespace_line_trim'].$this->options['tag_variable'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. + '|'. + preg_quote($this->options['tag_variable'][1], '#'). + ') + }Ax'; + } + + /** + * @return string + */ + public function getBlockRegex() + { + return '{ + \s* + (?:'. + preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*\n?'. + '|'. + preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. + '|'. + preg_quote($this->options['tag_block'][1], '#').'\n?'. + ') + }Ax'; + } + + /** + * @return string + */ + public function getRawDataRegex() + { + return '{'. + preg_quote($this->options['tag_block'][0], '#'). + '('. + $this->options['whitespace_trim']. + '|'. + $this->options['whitespace_line_trim']. + ')?\s*endverbatim\s*'. + '(?:'. + preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. + '|'. + preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. + '|'. + preg_quote($this->options['tag_block'][1], '#'). + ') + }sx'; + } + + /** + * @return string + */ + public function getOperatorRegex() + { + $operators = array_merge( + ['='], + array_keys($this->env->getUnaryOperators()), + array_keys($this->env->getBinaryOperators()) + ); + + $operators = array_combine($operators, array_map('strlen', $operators)); + arsort($operators); + + $regex = []; + foreach ($operators as $operator => $length) { + if (ctype_alpha($operator[$length - 1])) { + $r = preg_quote($operator, '/').'(?=[\s()])'; + } else { + $r = preg_quote($operator, '/'); + } + + $r = preg_replace('/\s+/', '\s+', $r); + + $regex[] = $r; + } + + return '/'.implode('|', $regex).'/A'; + } + + /** + * @return string + */ + public function getCommentRegex() + { + return '{ + (?:'. + preg_quote($this->options['whitespace_trim']).preg_quote($this->options['tag_comment'][1], '#').'\s*\n?'. + '|'. + preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. + '|'. + preg_quote($this->options['tag_comment'][1], '#').'\n?'. + ') + }sx'; + } + + /** + * @return string + */ + public function getBlockRawRegex() + { + return '{ + \s*verbatim\s* + (?:'. + preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. + '|'. + preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. + '|'. + preg_quote($this->options['tag_block'][1], '#'). + ') + }Asx'; + } + + /** + * @return string + */ + public function getBlockLineRegex() + { + return '{\s*line\s+(\d+)\s*'.preg_quote($this->options['tag_block'][1], '#').'}As'; + } + + /** + * @return string + */ + public function getTokensStartRegex() + { + return '{ + ('. + 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'], '#'). + '|'. + preg_quote($this->options['whitespace_line_trim'], '#'). + ')? + }sx'; + } + + /** + * @return string + */ + public function getInterpolationStartRegex() + { + return '{'.preg_quote($this->options['interpolation'][0], '#').'\s*}A'; + } + + /** + * @return string + */ + public function getInterpolationEndRegex() + { + return '{\s*'.preg_quote($this->options['interpolation'][1], '#').'}A'; + } +} diff --git a/TwigCS/Token/TokenizerInterface.php b/TwigCS/Token/TokenizerInterface.php new file mode 100644 index 0000000..d521b4a --- /dev/null +++ b/TwigCS/Token/TokenizerInterface.php @@ -0,0 +1,18 @@ +add($command); +$application->setDefaultCommand($command->getName()); +$application->run(); diff --git a/build.xml b/build.xml index 1c07975..884e7ae 100644 --- a/build.xml +++ b/build.xml @@ -33,6 +33,10 @@ + + + + diff --git a/composer.json b/composer.json index 8d53724..88cf26a 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "keywords": [ "Symfony", "coding standard", - "phpcs" + "phpcs", + "twigcs" ], "homepage": "https://github.com/VincentLanglet/Symfony-custom-coding-standard", "license": "MIT", @@ -14,10 +15,20 @@ "name": "Vincent Langlet" } ], + "bin": ["bin/twigcs"], "require": { - "squizlabs/php_codesniffer": "3.4.*" + "squizlabs/php_codesniffer": "3.4.*", + "twig/twig": "^2.0", + "symfony/console": "^3.0 || ^4.0", + "symfony/finder": "^3.0 || ^4.0" }, "require-dev": { "phpunit/phpunit": "^7.0" + }, + "autoload": { + "psr-4": { "TwigCS\\": "TwigCS/" } + }, + "autoload-dev": { + "psr-4": { "TwigCS\\Tests\\": "TwigCS/Tests/" } } } diff --git a/composer.lock b/composer.lock index 0f5bac2..b2b7ed3 100644 --- a/composer.lock +++ b/composer.lock @@ -1,10 +1,10 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "57c75679069eb5c2e455bd512389b1f6", + "content-hash": "2891626665524c1259b4c522176d8296", "packages": [ { "name": "squizlabs/php_codesniffer", @@ -56,6 +56,382 @@ "standards" ], "time": "2019-04-10T23:49:02+00:00" + }, + { + "name": "symfony/console", + "version": "v4.2.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "e2840bb38bddad7a0feaf85931e38fdcffdb2f81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/e2840bb38bddad7a0feaf85931e38fdcffdb2f81", + "reference": "e2840bb38bddad7a0feaf85931e38fdcffdb2f81", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/contracts": "^1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/dependency-injection": "<3.4", + "symfony/process": "<3.3" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/lock": "~3.4|~4.0", + "symfony/process": "~3.4|~4.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "time": "2019-04-08T14:23:48+00:00" + }, + { + "name": "symfony/contracts", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/contracts.git", + "reference": "d3636025e8253c6144358ec0a62773cae588395b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/contracts/zipball/d3636025e8253c6144358ec0a62773cae588395b", + "reference": "d3636025e8253c6144358ec0a62773cae588395b", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "require-dev": { + "psr/cache": "^1.0", + "psr/container": "^1.0", + "symfony/polyfill-intl-idn": "^1.10" + }, + "suggest": { + "psr/cache": "When using the Cache contracts", + "psr/container": "When using the Service contracts", + "symfony/cache-contracts-implementation": "", + "symfony/event-dispatcher-implementation": "", + "symfony/http-client-contracts-implementation": "", + "symfony/service-contracts-implementation": "", + "symfony/translation-contracts-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\": "" + }, + "exclude-from-classmap": [ + "**/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A set of abstractions extracted out of the Symfony components", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-04-27T14:29:50+00:00" + }, + { + "name": "symfony/finder", + "version": "v4.2.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e45135658bd6c14b61850bf131c4f09a55133f69" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e45135658bd6c14b61850bf131c4f09a55133f69", + "reference": "e45135658bd6c14b61850bf131c4f09a55133f69", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "time": "2019-04-06T13:51:08+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "82ebae02209c21113908c229e9883c419720738a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "twig/twig", + "version": "v2.10.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "5240e21982885b76629552d83b4ebb6d41ccde6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/5240e21982885b76629552d83b4ebb6d41ccde6b", + "reference": "5240e21982885b76629552d83b4ebb6d41ccde6b", + "shasum": "" + }, + "require": { + "php": "^7.0", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0", + "symfony/debug": "^2.7", + "symfony/phpunit-bridge": "^3.4.19|^4.1.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.10-dev" + } + }, + "autoload": { + "psr-0": { + "Twig_": "lib/" + }, + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + }, + { + "name": "Twig Team", + "homepage": "https://twig.symfony.com/contributors", + "role": "Contributors" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "time": "2019-05-14T12:03:52+00:00" } ], "packages-dev": [ @@ -321,16 +697,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08" + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", "shasum": "" }, "require": { @@ -368,7 +744,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-11-30T07:14:17+00:00" + "time": "2019-04-30T17:48:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -734,16 +1110,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.5.9", + "version": "7.5.11", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "134669cf0eeac3f79bc7f0c793efbc158bffc160" + "reference": "64cb33f5b520da490a7b13149d39b43cf3c890c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/134669cf0eeac3f79bc7f0c793efbc158bffc160", - "reference": "134669cf0eeac3f79bc7f0c793efbc158bffc160", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/64cb33f5b520da490a7b13149d39b43cf3c890c6", + "reference": "64cb33f5b520da490a7b13149d39b43cf3c890c6", "shasum": "" }, "require": { @@ -814,7 +1190,7 @@ "testing", "xunit" ], - "time": "2019-04-19T15:50:46+00:00" + "time": "2019-05-14T04:53:02+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -983,16 +1359,16 @@ }, { "name": "sebastian/environment", - "version": "4.2.1", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "3095910f0f0fb155ac4021fc51a4a7a39ac04e8a" + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/3095910f0f0fb155ac4021fc51a4a7a39ac04e8a", - "reference": "3095910f0f0fb155ac4021fc51a4a7a39ac04e8a", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404", + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404", "shasum": "" }, "require": { @@ -1032,7 +1408,7 @@ "environment", "hhvm" ], - "time": "2019-04-25T07:55:20+00:00" + "time": "2019-05-05T09:05:15+00:00" }, { "name": "sebastian/exporter", @@ -1382,64 +1758,6 @@ "homepage": "https://github.com/sebastianbergmann/version", "time": "2016-10-03T07:35:21+00:00" }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.11.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "82ebae02209c21113908c229e9883c419720738a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", - "reference": "82ebae02209c21113908c229e9883c419720738a", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.11-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "time": "2019-02-06T07:57:58+00:00" - }, { "name": "theseer/tokenizer", "version": "1.1.2",