diff --git a/README.md b/README.md
index fad8c6d..79dc50f 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,9 @@ Like what we do? Want to join us? Check out our job listings on our [career page
----
[BitBag](https://bitbag.io/) coding standard helps you produce solid and maintainable code.
At [BitBag Coding Bible](https://github.com/BitBagCommerce/BitBagBible) you can get familiar with standard we have
-implemented in our library. [ECS](https://github.com/symplify/easy-coding-standard)
-and [PHPStan](https://github.com/phpstan/phpstan) are responsible for keeping your code in order.
+implemented in our library. [ECS](https://github.com/symplify/easy-coding-standard),
+[PHPStan](https://github.com/phpstan/phpstan)
+and [Twigcs](https://github.com/friendsoftwig/twigcs) are responsible for keeping your code in order.
## We are here to help
This **open-source library was developed to help the community**. If you have any additional questions, would like help with installing or configuring the plugin, or need any assistance with your project - let us know!
@@ -68,6 +69,23 @@ return static function (ContainerConfigurator $containerConfigurator): void {
```
+For Twigcs create `.twig_cs.dist` file with the following lines
+```php
+
+setName('bitbag_config')
+ ->setRuleSet(Ruleset::class)
+;
+
+```
+
## Usage
@@ -79,6 +97,11 @@ If ECS found any standard violations, you can fix it by:
```bash
./vendor/bin/ecs check src --fix
```
+To verify your Twig templates in /templates dir:
+```bash
+./vendor/bin/twigcs templates
+```
+
## Customization
#### ECS
@@ -98,6 +121,23 @@ You can set PHPStan rule level with following commands
./vendor/bin/phpstan analyze --configuration=vendor/bitbag/coding-standard/phpstan.neon tests --level=5
```
+#### Twigcs
+You can set path to your Twig templates in the configuration file so you don't have to provide it in the twigcs command
+```php
+setName('bitbag_config')
+ ->setRuleSet(Ruleset::class)
+ ->addFinder(TemplateFinder::create()->in(__DIR__.'/templates'))
+;
+```
# About us
diff --git a/composer.json b/composer.json
index 593d256..19e5e37 100644
--- a/composer.json
+++ b/composer.json
@@ -12,11 +12,20 @@
"phpstan/phpstan-webmozart-assert": "^1.0",
"symplify/easy-coding-standard": "^8.3 || ^9.4 || ^10.0 || ^11.0",
"friendsofphp/php-cs-fixer": "^3.0",
- "slevomat/coding-standard": "^7.0"
+ "slevomat/coding-standard": "^7.0",
+ "friendsoftwig/twigcs": "^6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9"
},
"autoload": {
"psr-4": {
"BitBag\\CodingStandard\\": "src/"
}
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "BitBag\\CodingStandard\\Tests": "tests/"
+ }
}
}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..0dbcd21
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+
+ tests/Integration
+
+
+
+
+
+
+
+
+
diff --git a/src/Twigcs/Dto/HtmlTagDto.php b/src/Twigcs/Dto/HtmlTagDto.php
new file mode 100644
index 0000000..767a1b3
--- /dev/null
+++ b/src/Twigcs/Dto/HtmlTagDto.php
@@ -0,0 +1,60 @@
+tag;
+ }
+
+ public function setTag(string $tag): self
+ {
+ $this->tag = $tag;
+
+ return $this;
+ }
+
+ public function getHtml(): string
+ {
+ return $this->html;
+ }
+
+ public function setHtml(string $html): self
+ {
+ $this->html = $html;
+
+ return $this;
+ }
+
+ public function getOffset(): int
+ {
+ return $this->offset;
+ }
+
+ public function setOffset(int $offset): self
+ {
+ $this->offset = $offset;
+
+ return $this;
+ }
+}
diff --git a/src/Twigcs/Dto/OffsetDto.php b/src/Twigcs/Dto/OffsetDto.php
new file mode 100644
index 0000000..18be115
--- /dev/null
+++ b/src/Twigcs/Dto/OffsetDto.php
@@ -0,0 +1,45 @@
+line;
+ }
+
+ public function setLine(int $line): self
+ {
+ $this->line = $line;
+
+ return $this;
+ }
+
+ public function getColumn(): int
+ {
+ return $this->column;
+ }
+
+ public function setColumn(int $column): self
+ {
+ $this->column = $column;
+
+ return $this;
+ }
+}
diff --git a/src/Twigcs/Rule/EmptyLinesRule.php b/src/Twigcs/Rule/EmptyLinesRule.php
new file mode 100644
index 0000000..c48fae3
--- /dev/null
+++ b/src/Twigcs/Rule/EmptyLinesRule.php
@@ -0,0 +1,73 @@
+\n{3,})#s';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ /** @var int */
+ private $lineNumberOffset = 2;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+
+ $content = str_replace("\r", '', $tokens->getSourceContext()->getCode());
+ $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($content);
+
+ foreach ($this->getMultiLines($content) as $multiline) {
+ if ($this->htmlUtil->isInsideUnnecessaryTag($multiline['offset'][1])) {
+ continue;
+ }
+
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $multiline['offset'][1] + $this->lineNumberOffset);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ 0,
+ Ruleset::ERROR_MULTIPLE_EMPTY_LINES
+ );
+ }
+
+ return $violations;
+ }
+
+ private function getMultiLines(string $content): array
+ {
+ return preg_match_all($this->pattern, $content, $multilines, HtmlUtil::REGEX_FLAGS)
+ ? $multilines
+ : [];
+ }
+}
diff --git a/src/Twigcs/Rule/Html/ApostropheInAttributesRule.php b/src/Twigcs/Rule/Html/ApostropheInAttributesRule.php
new file mode 100644
index 0000000..997a0e8
--- /dev/null
+++ b/src/Twigcs/Rule/Html/ApostropheInAttributesRule.php
@@ -0,0 +1,66 @@
+')#";
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+ $content = $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($tokens->getSourceContext()->getCode());
+
+ foreach ($this->htmlUtil->getParsedHtmlTags($content) as $tag) {
+ foreach ($this->getApostrophes($tag->getHtml()) as $apostrophe) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $tag->getOffset() + $apostrophe['offset'][1]);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ sprintf(Ruleset::ERROR_APOSTROPHE_IN_ATTRIBUTE, $tag->getTag())
+ );
+ }
+ }
+
+ return $violations;
+ }
+
+ private function getApostrophes(string $html): array
+ {
+ return preg_match_all($this->pattern, $html, $quotes, HtmlUtil::REGEX_FLAGS)
+ ? $quotes
+ : [];
+ }
+}
diff --git a/src/Twigcs/Rule/Html/MultiWhitespaceInAttributesRule.php b/src/Twigcs/Rule/Html/MultiWhitespaceInAttributesRule.php
new file mode 100644
index 0000000..ad50792
--- /dev/null
+++ b/src/Twigcs/Rule/Html/MultiWhitespaceInAttributesRule.php
@@ -0,0 +1,66 @@
+\s{2,})#';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+ $content = $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($tokens->getSourceContext()->getCode());
+
+ foreach ($this->htmlUtil->getParsedHtmlTags($content) as $tag) {
+ foreach ($this->getMultiSpaces($tag->getHtml()) as $space) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $tag->getOffset() + $space['offset'][1]);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ sprintf(Ruleset::ERROR_MULTIPLE_WHITESPACES, $tag->getTag())
+ );
+ }
+ }
+
+ return $violations;
+ }
+
+ private function getMultiSpaces(string $html): array
+ {
+ return preg_match_all($this->pattern, $html, $spaces, HtmlUtil::REGEX_FLAGS)
+ ? $spaces
+ : [];
+ }
+}
diff --git a/src/Twigcs/Rule/Html/UnclosedVoidTagsRule.php b/src/Twigcs/Rule/Html/UnclosedVoidTagsRule.php
new file mode 100644
index 0000000..357b7f1
--- /dev/null
+++ b/src/Twigcs/Rule/Html/UnclosedVoidTagsRule.php
@@ -0,0 +1,71 @@
+$#';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+ $content = $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($tokens->getSourceContext()->getCode());
+
+ foreach ($this->htmlUtil->getParsedHtmlTags($content) as $tag) {
+ if ($this->isTagUnclosed($tag)) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $tag->getOffset());
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ sprintf(Ruleset::ERROR_UNCLOSED_VOID_HTML_TAG, $tag->getTag())
+ );
+ }
+ }
+
+ return $violations;
+ }
+
+ private function isTagUnclosed(HtmlTagDto $tag): bool
+ {
+ return in_array($tag->getTag(), $this->tags)
+ && !preg_match($this->pattern, $tag->getHtml());
+ }
+}
diff --git a/src/Twigcs/Rule/Html/WhitespaceInAttributesRule.php b/src/Twigcs/Rule/Html/WhitespaceInAttributesRule.php
new file mode 100644
index 0000000..5d43b59
--- /dev/null
+++ b/src/Twigcs/Rule/Html/WhitespaceInAttributesRule.php
@@ -0,0 +1,66 @@
+[^\s/>])#s';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+ $content = $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($tokens->getSourceContext()->getCode());
+
+ foreach ($this->htmlUtil->getParsedHtmlTags($content) as $tag) {
+ foreach ($this->getNoSpaces($tag->getHtml()) as $noSpace) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $tag->getOffset() + $noSpace['offset'][1]);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ sprintf(Ruleset::ERROR_NO_SPACE_BETWEEN_ATTRIBUTES, $tag->getTag())
+ );
+ }
+ }
+
+ return $violations;
+ }
+
+ private function getNoSpaces(string $html): array
+ {
+ return preg_match_all($this->pattern, $html, $noSpaces, HtmlUtil::REGEX_FLAGS)
+ ? $noSpaces
+ : [];
+ }
+}
diff --git a/src/Twigcs/Rule/LineLengthRule.php b/src/Twigcs/Rule/LineLengthRule.php
new file mode 100644
index 0000000..7ca3e29
--- /dev/null
+++ b/src/Twigcs/Rule/LineLengthRule.php
@@ -0,0 +1,72 @@
+htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+
+ $content = str_replace("\r", '', $tokens->getSourceContext()->getCode());
+ $lines = explode("\n", $content);
+
+ $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($content);
+ $currentPosition = 0;
+
+ foreach ($lines as $lineNumber => $line) {
+ $lineLength = mb_strlen($line);
+ $currentPosition += $lineLength;
+
+ if ($this->isLineTooLong($lineLength) && !$this->htmlUtil->isInsideUnnecessaryTag($currentPosition)) {
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $lineNumber + 1,
+ $this->maxLineLength,
+ sprintf(Ruleset::ERROR_LINE_TOO_LONG, $this->maxLineLength)
+ );
+ }
+
+ ++$currentPosition;
+ }
+
+ return $violations;
+ }
+
+ private function isLineTooLong(int $lineLength): bool
+ {
+ return $lineLength > $this->maxLineLength;
+ }
+}
diff --git a/src/Twigcs/Rule/NewlineAtTheEndRule.php b/src/Twigcs/Rule/NewlineAtTheEndRule.php
new file mode 100644
index 0000000..5c91b66
--- /dev/null
+++ b/src/Twigcs/Rule/NewlineAtTheEndRule.php
@@ -0,0 +1,60 @@
+htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+
+ $content = str_replace("\r", '', $tokens->getSourceContext()->getCode());
+
+ if ($this->isNoNewline($content)) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, mb_strlen($content));
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ Ruleset::ERROR_NO_NEW_LINE_AT_THE_END
+ );
+ }
+
+ return $violations;
+ }
+
+ private function isNoNewline(string $content): bool
+ {
+ return $content && "\n" !== mb_substr($content, -1);
+ }
+}
diff --git a/src/Twigcs/Rule/Twig/MacroRule.php b/src/Twigcs/Rule/Twig/MacroRule.php
new file mode 100644
index 0000000..cd8495b
--- /dev/null
+++ b/src/Twigcs/Rule/Twig/MacroRule.php
@@ -0,0 +1,105 @@
+isMacro = false;
+ $path = $tokens->getSourceContext()->getPath();
+
+ while (!$tokens->isEOF()) {
+ $token = $tokens->getCurrent();
+
+ if ($violation = $this->checkMultipleMacros($token, $path)) {
+ $violations[] = $violation;
+ }
+ if ($violation = $this->checkSelfMacro($tokens, $token, $path)) {
+ $violations[] = $violation;
+ }
+
+ $tokens->next();
+ }
+
+ return $violations;
+ }
+
+ private function checkMultipleMacros(Token $token, string $filename): ?Violation
+ {
+ if (
+ Token::NAME_TYPE === $token->getType()
+ && $this->twigTagMacro === $token->getValue()
+ ) {
+ if (!$this->isMacro) {
+ $this->isMacro = true;
+ } else {
+ return $this->createViolation(
+ $filename,
+ $token->getLine(),
+ $token->getColumn(),
+ Ruleset::ERROR_MULTIPLE_MACROS
+ );
+ }
+ }
+
+ return null;
+ }
+
+ private function checkSelfMacro(TokenStream $tokens, Token $token, string $filename): ?Violation
+ {
+ if ($this->isSelfTagUsedForMacro($tokens, $token)) {
+ return $this->createViolation(
+ $filename,
+ $token->getLine(),
+ $token->getColumn(),
+ Ruleset::ERROR_MACRO_IN_TEMPLATE
+ );
+ }
+
+ return null;
+ }
+
+ private function isSelfTagUsedForMacro(TokenStream $tokens, Token $token): bool
+ {
+ return Token::NAME_TYPE === $token->getType()
+ && $this->twigTagSelfMacro === $token->getValue()
+
+ && Token::PUNCTUATION_TYPE === $tokens->look(1)->getType()
+ && '.' === $tokens->look(1)->getValue()
+
+ && Token::NAME_TYPE === $tokens->look(2)->getType()
+
+ && Token::PUNCTUATION_TYPE === $tokens->look(3)->getType()
+ && '(' === $tokens->look(3)->getValue();
+ }
+}
diff --git a/src/Twigcs/Rule/Twig/NewlineAfterSetRule.php b/src/Twigcs/Rule/Twig/NewlineAfterSetRule.php
new file mode 100644
index 0000000..e8de9fe
--- /dev/null
+++ b/src/Twigcs/Rule/Twig/NewlineAfterSetRule.php
@@ -0,0 +1,70 @@
+[^\n])#s';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+
+ $content = str_replace("\r", '', $tokens->getSourceContext()->getCode());
+ $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($content);
+
+ foreach ($this->getNoNewlinesAfterSet($content) as $noNewLine) {
+ if ($this->htmlUtil->isInsideUnnecessaryTag($noNewLine['offset'][1])) {
+ continue;
+ }
+
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $noNewLine['offset'][1]);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ Ruleset::ERROR_NO_NEW_LINE_AFTER_SET
+ );
+ }
+
+ return $violations;
+ }
+
+ private function getNoNewlinesAfterSet(string $content): array
+ {
+ return preg_match_all($this->pattern, $content, $noNewLines, HtmlUtil::REGEX_FLAGS)
+ ? $noNewLines
+ : [];
+ }
+}
diff --git a/src/Twigcs/Rule/Twig/QuoteInTwigRule.php b/src/Twigcs/Rule/Twig/QuoteInTwigRule.php
new file mode 100644
index 0000000..e665619
--- /dev/null
+++ b/src/Twigcs/Rule/Twig/QuoteInTwigRule.php
@@ -0,0 +1,74 @@
+")[^"]*"#s';
+
+ /** @var HtmlUtil */
+ private $htmlUtil;
+
+ public function __construct(int $severity, HtmlUtil $htmlUtil)
+ {
+ parent::__construct($severity);
+
+ $this->htmlUtil = $htmlUtil;
+ }
+
+ /**
+ * @return Violation[]
+ */
+ public function check(TokenStream $tokens): array
+ {
+ $violations = [];
+
+ $content = $this->htmlUtil->stripUnnecessaryTagsAndSavePositions($tokens->getSourceContext()->getCode());
+
+ foreach ($this->getMatches($this->patternTwigTags, $content) as $tag) {
+ if ($this->htmlUtil->isInsideUnnecessaryTag($tag[0][1])) {
+ continue;
+ }
+
+ foreach ($this->getMatches($this->patternQuotes, $tag[0][0]) as $quote) {
+ $offset = $this->htmlUtil->getTwigcsOffset($content, $tag[0][1] + $quote['offset'][1]);
+
+ $violations[] = $this->createViolation(
+ $tokens->getSourceContext()->getPath(),
+ $offset->getLine(),
+ $offset->getColumn(),
+ Ruleset::ERROR_QUOTE_IN_TWIG
+ );
+ }
+ }
+
+ return $violations;
+ }
+
+ private function getMatches(string $pattern, string $content): array
+ {
+ return preg_match_all($pattern, $content, $matches, HtmlUtil::REGEX_FLAGS)
+ ? $matches
+ : [];
+ }
+}
diff --git a/src/Twigcs/Ruleset/Ruleset.php b/src/Twigcs/Ruleset/Ruleset.php
new file mode 100644
index 0000000..3e9bfb8
--- /dev/null
+++ b/src/Twigcs/Ruleset/Ruleset.php
@@ -0,0 +1,79 @@
+ HTML void tag should be closed.';
+ public const ERROR_MULTIPLE_MACROS = 'There should only be one macro in the same file.';
+ public const ERROR_MACRO_IN_TEMPLATE = 'There should not be a macro in the template file.';
+ public const ERROR_MULTIPLE_WHITESPACES = 'There should not be so many whitespaces in <%s> HTML tag attributes.';
+ public const ERROR_APOSTROPHE_IN_ATTRIBUTE = 'A quote should be used instead of apostrophe in <%s> HTML tag attributes.';
+ public const ERROR_NO_SPACE_BETWEEN_ATTRIBUTES = 'There should be a whitespace between attributes in <%s> HTML tag.';
+ public const ERROR_NO_NEW_LINE_AT_THE_END = 'There should be a new line at the end of the file.';
+ public const ERROR_LINE_TOO_LONG = 'Line should be up to %d characters long.';
+ public const ERROR_MULTIPLE_EMPTY_LINES = 'There should not be so many empty lines.';
+ public const ERROR_NO_NEW_LINE_AFTER_SET = 'There should be a new line after twig {% set %} declaration.';
+ public const ERROR_QUOTE_IN_TWIG = 'An apostrophe should be used instead of quote in Twig tag.';
+
+ /** @var int */
+ private $twigMajorVersion;
+
+ /** @var string[] */
+ private $forbiddenTwigFunctions = [
+ 'dump',
+ ];
+
+ public function __construct(int $twigMajorVersion)
+ {
+ $this->twigMajorVersion = $twigMajorVersion;
+ }
+
+ public function getRules()
+ {
+ $rulesetConfigurator = (new RulesetConfigurator())
+ ->setTwigMajorVersion($this->twigMajorVersion);
+ $twigcsRulesetBuilder = new RulesetBuilder($rulesetConfigurator);
+
+ $htmlUtil = new HtmlUtil();
+
+ return [
+ new BitBagRule\EmptyLinesRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\LineLengthRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\NewlineAtTheEndRule(Violation::SEVERITY_ERROR, $htmlUtil),
+
+ new BitBagRule\Html\ApostropheInAttributesRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\Html\MultiWhitespaceInAttributesRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\Html\UnclosedVoidTagsRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\Html\WhitespaceInAttributesRule(Violation::SEVERITY_ERROR, $htmlUtil),
+
+ new BitBagRule\Twig\MacroRule(Violation::SEVERITY_ERROR),
+ new BitBagRule\Twig\NewlineAfterSetRule(Violation::SEVERITY_ERROR, $htmlUtil),
+ new BitBagRule\Twig\QuoteInTwigRule(Violation::SEVERITY_ERROR, $htmlUtil),
+
+ new TwigcsRule\ForbiddenFunctions(Violation::SEVERITY_ERROR, $this->forbiddenTwigFunctions),
+ new TwigcsRule\LowerCaseVariable(Violation::SEVERITY_ERROR),
+ new TwigcsRule\RegEngineRule(Violation::SEVERITY_ERROR, $twigcsRulesetBuilder->build()),
+ new TwigcsRule\TrailingSpace(Violation::SEVERITY_ERROR),
+ new TwigcsRule\UnusedMacro(Violation::SEVERITY_WARNING),
+ new TwigcsRule\UnusedVariable(Violation::SEVERITY_WARNING),
+ ];
+ }
+}
diff --git a/src/Twigcs/Util/HtmlUtil.php b/src/Twigcs/Util/HtmlUtil.php
new file mode 100644
index 0000000..4b8acdf
--- /dev/null
+++ b/src/Twigcs/Util/HtmlUtil.php
@@ -0,0 +1,109 @@
+#s',
+ '##s',
+ '#<\s*script[^>]*[^/]>(.*?)<\s*/\s*script\s*>#s',
+ '#<\s*script\s*>(.*?)<\s*/\s*script\s*>#s',
+ '#<\s*style[^>]*[^/]>(.*?)<\s*/\s*style\s*>#s',
+ '#<\s*style\s*>(.*?)<\s*/\s*style\s*>#s',
+ ];
+
+ private const HTML_TAG_PATTERN = '#?\s*(?\w+).*?>#s';
+
+ /** @var array */
+ private $unnecessaryTagsPositions = [];
+
+ /**
+ * @return HtmlTagDto[]
+ */
+ public function getParsedHtmlTags(string $html): array
+ {
+ $ret = [];
+
+ if (preg_match_all(self::HTML_TAG_PATTERN, $html, $matches, self::REGEX_FLAGS)) {
+ foreach ($matches as $match) {
+ $ret[] = (new HtmlTagDto())
+ ->setTag($match['tag'][0])
+ ->setHtml($match[0][0])
+ ->setOffset($match[0][1]);
+ }
+ }
+
+ return $ret;
+ }
+
+ public function getTwigcsOffset(string $html, int $length): OffsetDto
+ {
+ $substr = mb_substr($html, 0, $length);
+ $lines = explode("\n", $substr);
+ $linesCount = count($lines);
+
+ return (new OffsetDto())
+ ->setLine($linesCount)
+ ->setColumn(mb_strlen($lines[$linesCount - 1]) + 1);
+ }
+
+ public function isInsideUnnecessaryTag(int $position): bool
+ {
+ foreach ($this->unnecessaryTagsPositions as $tagsPosition) {
+ if ($tagsPosition[0] <= $position && $position < $tagsPosition[1]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function stripUnnecessaryTagsAndSavePositions(string $html): string
+ {
+ $tags = [];
+ $this->unnecessaryTagsPositions = [];
+
+ foreach (self::UNNECESSARY_TAGS as $tag) {
+ $tags[$tag] = function ($m) { return $this->replaceToTwigcsStringPad($m[0]); };
+ }
+
+ return preg_replace_callback_array(
+ $tags,
+ str_replace("\r", '', $html),
+ -1,
+ $count,
+ self::REGEX_FLAGS
+ );
+ }
+
+ private function replaceToTwigcsStringPad(array $match): string
+ {
+ $html = $match[0];
+ $offset = $match[1];
+
+ $matchLength = mb_strlen($html);
+ $matchLinesCount = mb_substr_count($html, "\n");
+
+ $this->unnecessaryTagsPositions[] = [
+ $offset, $offset + $matchLength,
+ ];
+
+ return str_pad('', $matchLength - $matchLinesCount, 'A') . str_repeat("\n", $matchLinesCount);
+ }
+}
diff --git a/tests/Integration/BaseIntegrationTest.php b/tests/Integration/BaseIntegrationTest.php
new file mode 100644
index 0000000..0569d53
--- /dev/null
+++ b/tests/Integration/BaseIntegrationTest.php
@@ -0,0 +1,27 @@
+rule = new EmptyLinesRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_are_multi_empty_lines()
+ {
+ $html = '
+
+
+ ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(3, $violations[0]->getLine());
+ self::assertEquals(0, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_MULTIPLE_EMPTY_LINES, $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_there_are_not_multi_empty_lines()
+ {
+ $html = '
+
+
+ ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Html/ApostropheInAttributesRuleTest.php b/tests/Integration/Twigcs/Html/ApostropheInAttributesRuleTest.php
new file mode 100644
index 0000000..3df87f0
--- /dev/null
+++ b/tests/Integration/Twigcs/Html/ApostropheInAttributesRuleTest.php
@@ -0,0 +1,58 @@
+rule = new ApostropheInAttributesRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_is_apostrophe_in_attributes()
+ {
+ $html = "some content
";
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(12, $violations[0]->getColumn());
+ self::assertEquals(sprintf(Ruleset::ERROR_APOSTROPHE_IN_ATTRIBUTE, 'div'), $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_is_quote_in_attributes()
+ {
+ $html = 'some content
some content';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Html/MultiWhitespaceInAttributesRuleTest.php b/tests/Integration/Twigcs/Html/MultiWhitespaceInAttributesRuleTest.php
new file mode 100644
index 0000000..c437d86
--- /dev/null
+++ b/tests/Integration/Twigcs/Html/MultiWhitespaceInAttributesRuleTest.php
@@ -0,0 +1,58 @@
+rule = new MultiWhitespaceInAttributesRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_are_multiple_spaces_in_attributes()
+ {
+ $html = 'some content
';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(23, $violations[0]->getColumn());
+ self::assertEquals(sprintf(Ruleset::ERROR_MULTIPLE_WHITESPACES, 'div'), $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_there_are_not_multiple_spaces_in_attributes()
+ {
+ $html = 'some content
some content';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Html/UnclosedVoidTagsRuleTest.php b/tests/Integration/Twigcs/Html/UnclosedVoidTagsRuleTest.php
new file mode 100644
index 0000000..2876400
--- /dev/null
+++ b/tests/Integration/Twigcs/Html/UnclosedVoidTagsRuleTest.php
@@ -0,0 +1,59 @@
+rule = new UnclosedVoidTagsRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_tag_is_unclosed()
+ {
+ $html = 'some content
';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(14, $violations[0]->getColumn());
+ self::assertEquals(sprintf(Ruleset::ERROR_UNCLOSED_VOID_HTML_TAG, 'img'), $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_tags_are_closed()
+ {
+ $html = 'content
'.
+ ' ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Html/WhitespaceInAttributesRuleTest.php b/tests/Integration/Twigcs/Html/WhitespaceInAttributesRuleTest.php
new file mode 100644
index 0000000..2ab1ba7
--- /dev/null
+++ b/tests/Integration/Twigcs/Html/WhitespaceInAttributesRuleTest.php
@@ -0,0 +1,58 @@
+rule = new WhitespaceInAttributesRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_is_no_space_between_attributes()
+ {
+ $html = 'content content ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(32, $violations[0]->getColumn());
+ self::assertEquals(sprintf(Ruleset::ERROR_NO_SPACE_BETWEEN_ATTRIBUTES, 'span'), $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_is_exactly_one_space_between_attributes()
+ {
+ $html = 'content content ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/LineLengthRuleTest.php b/tests/Integration/Twigcs/LineLengthRuleTest.php
new file mode 100644
index 0000000..0705617
--- /dev/null
+++ b/tests/Integration/Twigcs/LineLengthRuleTest.php
@@ -0,0 +1,60 @@
+rule = new LineLengthRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_line_is_too_long()
+ {
+ $html = str_repeat('line', 31);
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(120, $violations[0]->getColumn());
+ self::assertEquals(sprintf(Ruleset::ERROR_LINE_TOO_LONG, 120), $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_there_are_not_too_long_lines()
+ {
+ $html = '
+
+ ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/NewlineAtTheEndRuleTest.php b/tests/Integration/Twigcs/NewlineAtTheEndRuleTest.php
new file mode 100644
index 0000000..7762917
--- /dev/null
+++ b/tests/Integration/Twigcs/NewlineAtTheEndRuleTest.php
@@ -0,0 +1,59 @@
+rule = new NewlineAtTheEndRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_there_is_no_new_line_at_the_end()
+ {
+ $html = 'content ';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(20, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_NO_NEW_LINE_AT_THE_END, $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_there_is_new_line_at_the_end()
+ {
+ $html = 'content
+';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Twig/MacroRuleTest.php b/tests/Integration/Twigcs/Twig/MacroRuleTest.php
new file mode 100644
index 0000000..18009a1
--- /dev/null
+++ b/tests/Integration/Twigcs/Twig/MacroRuleTest.php
@@ -0,0 +1,107 @@
+rule = new MacroRule(Violation::SEVERITY_ERROR);
+ }
+
+ public function test_it_returns_violation_when_are_multiple_macros_in_the_same_file()
+ {
+ $html = "{% macro button(name, value, type='text', size=20) %}
+
+{% macro textarea(name, value, type='text', size=20) %}";
+
+ $tokenStream = $this->getFinalTokenStream($html, [
+ new Token(Token::BLOCK_START_TYPE, '', 1, 1),
+ new Token(Token::NAME_TYPE, 'macro', 1, 4),
+ new Token(Token::BLOCK_END_TYPE, '', 1, 52),
+
+ new Token(Token::BLOCK_START_TYPE, '', 3, 1),
+ new Token(Token::NAME_TYPE, 'macro', 3, 4),
+ new Token(Token::BLOCK_END_TYPE, '', 3, 54),
+
+ new Token(Token::EOF_TYPE, '', 3, 56),
+ ]);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(3, $violations[0]->getLine());
+ self::assertEquals(4, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_MULTIPLE_MACROS, $violations[0]->getReason());
+ }
+
+ public function test_it_returns_violation_when_is_macro_in_the_template_file()
+ {
+ $html = "{% macro button(name, value, type='text', size=20) %}
+
+{% _self.button('someName', 'someValue') %}";
+
+ $tokenStream = $this->getFinalTokenStream($html, [
+ new Token(Token::BLOCK_START_TYPE, '', 3, 1),
+ new Token(Token::NAME_TYPE, '_self', 3, 4),
+ new Token(Token::PUNCTUATION_TYPE, '.', 3, 9),
+ new Token(Token::NAME_TYPE, 'button', 3, 10),
+ new Token(Token::PUNCTUATION_TYPE, '(', 3, 16),
+ new Token(Token::PUNCTUATION_TYPE, ')', 3, 40),
+ new Token(Token::BLOCK_END_TYPE, '', 3, 42),
+
+ new Token(Token::EOF_TYPE, '', 3, 44),
+ ]);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(3, $violations[0]->getLine());
+ self::assertEquals(4, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_MACRO_IN_TEMPLATE, $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_macro_is_in_the_separated_file()
+ {
+ $html = "{% macro button(name, value, type='text', size=20) %}";
+
+ $tokenStream = $this->getFinalTokenStream($html, [
+ new Token(Token::BLOCK_START_TYPE, '', 1, 1),
+ new Token(Token::NAME_TYPE, 'macro', 1, 4),
+ new Token(Token::BLOCK_END_TYPE, '', 1, 52),
+
+ new Token(Token::EOF_TYPE, '', 1, 54),
+ ]);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Twig/NewlineAfterSetRuleTest.php b/tests/Integration/Twigcs/Twig/NewlineAfterSetRuleTest.php
new file mode 100644
index 0000000..d2f8225
--- /dev/null
+++ b/tests/Integration/Twigcs/Twig/NewlineAfterSetRuleTest.php
@@ -0,0 +1,59 @@
+rule = new NewlineAfterSetRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_is_no_new_line_after_set()
+ {
+ $html = " {% set var = 'value' %} {{ var }} ";
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(37, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_NO_NEW_LINE_AFTER_SET, $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_is_new_line_after_set()
+ {
+ $html = " {% set var = 'value' %}
+ {{ var }} ";
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}
diff --git a/tests/Integration/Twigcs/Twig/QuoteInTwigRuleTest.php b/tests/Integration/Twigcs/Twig/QuoteInTwigRuleTest.php
new file mode 100644
index 0000000..bd0ea96
--- /dev/null
+++ b/tests/Integration/Twigcs/Twig/QuoteInTwigRuleTest.php
@@ -0,0 +1,58 @@
+rule = new QuoteInTwigRule(Violation::SEVERITY_ERROR, new HtmlUtil());
+ }
+
+ public function test_it_returns_violation_when_is_quote_in_twig()
+ {
+ $html = ' {% set var = "value" %}';
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(1, $violations);
+ self::assertInstanceOf(Violation::class, $violations[0]);
+
+ self::assertEquals(1, $violations[0]->getLine());
+ self::assertEquals(26, $violations[0]->getColumn());
+ self::assertEquals(Ruleset::ERROR_QUOTE_IN_TWIG, $violations[0]->getReason());
+ }
+
+ public function test_its_ok_when_is_apostrophe_in_twig()
+ {
+ $html = " {% set var = 'value' %}";
+ $tokenStream = $this->getFinalTokenStream($html);
+
+ $violations = $this->rule->check($tokenStream);
+
+ self::assertIsArray($violations);
+ self::assertCount(0, $violations);
+ }
+}