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 = '#\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); + } +}