Skip to content

Commit 14b73a4

Browse files
✨ Add TwigCS
1 parent cf5ec05 commit 14b73a4

31 files changed

+3547
-77
lines changed

TwigCS/Command/TwigCSCommand.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace TwigCS\Command;
4+
5+
use Symfony\Component\Console\Command\Command;
6+
use Symfony\Component\Console\Input\InputInterface;
7+
use Symfony\Component\Console\Input\InputOption;
8+
use Symfony\Component\Console\Output\OutputInterface;
9+
use Twig\Loader\ArrayLoader;
10+
use TwigCS\Config\Config;
11+
use TwigCS\Environment\StubbedEnvironment;
12+
use TwigCS\Linter;
13+
use TwigCS\Report\TextFormatter;
14+
use TwigCS\Ruleset\RulesetFactory;
15+
use TwigCS\Token\Tokenizer;
16+
17+
/**
18+
* TwigCS stands for "Twig Code Sniffer" and will check twig template againt all
19+
* rules which have been defined in the twigcs.yml of your project.
20+
*
21+
* This is heavily inspired by the symfony lint command and PHP_CodeSniffer tool
22+
* (https://github.com/squizlabs/PHP_CodeSniffer).
23+
*/
24+
class TwigCSCommand extends Command
25+
{
26+
protected function configure()
27+
{
28+
$this
29+
->setName('lint')
30+
->setDescription('Lints a template and outputs encountered errors')
31+
->setDefinition([
32+
new InputOption(
33+
'exclude',
34+
'',
35+
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
36+
'Excludes, based on regex, paths of files and folders from parsing',
37+
['vendor/']
38+
),
39+
new InputOption(
40+
'format',
41+
'',
42+
InputOption::VALUE_OPTIONAL,
43+
'Implemented formats are: full, text',
44+
'full'
45+
),
46+
new InputOption(
47+
'level',
48+
'',
49+
InputOption::VALUE_OPTIONAL,
50+
'Allowed values are: warning, error',
51+
'warning'
52+
),
53+
new InputOption(
54+
'working-dir',
55+
'',
56+
InputOption::VALUE_OPTIONAL,
57+
'Run as if this was started in <working-dir> instead of the current working directory',
58+
getcwd()
59+
),
60+
]);
61+
}
62+
63+
/**
64+
* @param InputInterface $input
65+
* @param OutputInterface $output
66+
*
67+
* @return int
68+
*
69+
* @throws \Exception
70+
*/
71+
protected function execute(InputInterface $input, OutputInterface $output)
72+
{
73+
$exclude = $input->getOption('exclude');
74+
$format = $input->getOption('format');
75+
$level = $input->getOption('level');
76+
$currentDir = $input->getOption('working-dir');
77+
78+
$config = new Config([
79+
'exclude' => $exclude,
80+
'workingDirectory' => $currentDir,
81+
]);
82+
83+
$twig = new StubbedEnvironment(new ArrayLoader(), ['stub_tags' => $config->get('stub')]);
84+
$linter = new Linter($twig, new Tokenizer($twig));
85+
$factory = new RulesetFactory();
86+
$reporter = $this->getReportFormatter($input, $output, $format);
87+
$exitCode = 0;
88+
89+
// Get the rules to apply.
90+
$ruleset = $factory->createStandardRuleset();
91+
92+
// Execute the linter.
93+
$report = $linter->run($config->findFiles(), $ruleset);
94+
95+
// Format the output.
96+
$reporter->display($report, ['level' => $level]);
97+
98+
// Return a meaningful error code.
99+
if ($report->getTotalErrors()) {
100+
$exitCode = 1;
101+
}
102+
103+
return $exitCode;
104+
}
105+
106+
/**
107+
* @param InputInterface $input
108+
* @param OutputInterface $output
109+
* @param string $format
110+
*
111+
* @return TextFormatter
112+
*
113+
* @throws \Exception
114+
*/
115+
protected function getReportFormatter($input, $output, $format)
116+
{
117+
switch ($format) {
118+
case 'full':
119+
return new TextFormatter($input, $output, ['explain' => true]);
120+
case 'text':
121+
return new TextFormatter($input, $output);
122+
default:
123+
throw new \Exception(sprintf('Unknown format "%s"', $format));
124+
}
125+
}
126+
}

TwigCS/Config/Config.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace TwigCS\Config;
4+
5+
use Symfony\Component\Finder\Finder;
6+
7+
/**
8+
* TwigCS configuration data.
9+
*/
10+
class Config
11+
{
12+
/**
13+
* Default configuration.
14+
*
15+
* @var array
16+
*/
17+
public static $defaultConfig = [
18+
'exclude' => [],
19+
'pattern' => '*.twig',
20+
'paths' => [],
21+
'stub' => [],
22+
'workingDirectory' => '',
23+
];
24+
25+
/**
26+
* Current configuration.
27+
*
28+
* @var array
29+
*/
30+
protected $config;
31+
32+
/**
33+
* Constructor.
34+
*/
35+
public function __construct()
36+
{
37+
$args = func_get_args();
38+
39+
$this->config = $this::$defaultConfig;
40+
foreach ($args as $arg) {
41+
$this->config = array_merge($this->config, $arg);
42+
}
43+
}
44+
45+
/**
46+
* Find all files to process, based on a file or directory and exclude patterns.
47+
*
48+
* @param string $fileOrDirectory a file or a directory.
49+
* @param array $exclude array of exclude patterns.
50+
*
51+
* @return array
52+
*/
53+
public function findFiles($fileOrDirectory = null, $exclude = null)
54+
{
55+
if (is_file($fileOrDirectory)) {
56+
// Early return with the given file. Should we exclude things to here?
57+
return [$fileOrDirectory];
58+
}
59+
60+
if (is_dir($fileOrDirectory)) {
61+
$fileOrDirectory = [$fileOrDirectory];
62+
}
63+
64+
if (!$fileOrDirectory) {
65+
$fileOrDirectory = $this->get('paths');
66+
$exclude = $this->get('exclude');
67+
}
68+
69+
// Build the finder.
70+
$files = Finder::create()
71+
->in($this->get('workingDirectory'))
72+
->name($this->config['pattern'])
73+
->files();
74+
75+
// Include all matching paths.
76+
foreach ($fileOrDirectory as $path) {
77+
$files->path($path);
78+
}
79+
80+
// Exclude all matching paths.
81+
if ($exclude) {
82+
$files->exclude($exclude);
83+
}
84+
85+
return $files;
86+
}
87+
88+
/**
89+
* Get a configuration value for the given $key.
90+
*
91+
* @param string $key
92+
*
93+
* @return mixed
94+
*/
95+
public function get($key)
96+
{
97+
if (!isset($this->config[$key])) {
98+
throw new \Exception(sprintf('Configuration key "%s" does not exist', $key));
99+
}
100+
101+
return $this->config[$key];
102+
}
103+
}

TwigCS/Config/Loader.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace TwigCS\Config;
4+
5+
use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader;
6+
use Symfony\Component\Yaml\Parser;
7+
8+
/**
9+
* Load a twigcs.yml file and validate its content.
10+
*/
11+
class Loader extends BaseFileLoader
12+
{
13+
/**
14+
* {@inheritdoc}
15+
*/
16+
public function load($resource, $type = null)
17+
{
18+
if (!stream_is_local($resource)) {
19+
throw new \InvalidArgumentException(sprintf('This is not a local file "%s".', $resource));
20+
}
21+
22+
// Try to find the path to the resource.
23+
try {
24+
$path = $this->locator->locate($resource);
25+
} catch (\InvalidArgumentException $e) {
26+
throw new \Exception(sprintf('File "%s" not found.', $resource), null, $e);
27+
}
28+
29+
// Load and parse the resource.
30+
$content = $this->loadResource($path);
31+
if (!$content) {
32+
// Empty resource, always return an array.
33+
$content = [];
34+
}
35+
36+
return $this->validate($content);
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function supports($resource, $type = null)
43+
{
44+
$validTypes = ['yaml', 'yml'];
45+
46+
return is_string($resource)
47+
&& in_array(pathinfo($resource, PATHINFO_EXTENSION), $validTypes, true)
48+
&& (!$type || in_array($type, $validTypes));
49+
}
50+
51+
/**
52+
* Load a resource and returns the parsed content.
53+
*
54+
* @param string $file
55+
*
56+
* @return array
57+
*
58+
* @throws \Exception If stream content has an invalid format.
59+
*/
60+
public function loadResource($file)
61+
{
62+
$parser = new Parser();
63+
try {
64+
return $parser->parse(file_get_contents($file));
65+
} catch (\Exception $e) {
66+
throw new \Exception(sprintf('Error parsing YAML, invalid file "%s"', $file), 0, $e);
67+
}
68+
}
69+
70+
/**
71+
* Validates the content $content parsed from $file.
72+
*
73+
* This default method, returns the content, as is, without any form of validation.
74+
*
75+
* @param array $content
76+
*
77+
* @return array
78+
*/
79+
protected function validate($content)
80+
{
81+
if (!isset($content['ruleset'])) {
82+
throw new \Exception(sprintf('Missing "%s" key', 'ruleset'));
83+
}
84+
85+
foreach ($content['ruleset'] as $rule) {
86+
if (!isset($rule['class'])) {
87+
throw new \Exception(sprintf('Missing "%s" key', 'class'));
88+
}
89+
}
90+
91+
return $content;
92+
}
93+
}

0 commit comments

Comments
 (0)